diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be05dc4ca..8dafb6c68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,6 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true - jobs: build: name: Build & test (${{ matrix.os }}) @@ -28,24 +27,14 @@ jobs: uses: actions/setup-dotnet@v6 with: global-json-file: global.json - # UseProjectReferences is passed explicitly on every leg rather than inherited from the - # default in Directory.Build.targets, so that changing that default can never silently - # change what any job builds. + # Explicit on every leg, so the default in Directory.Build.targets cannot silently + # change what a job builds. - name: Restore dependencies run: dotnet restore /p:UseProjectReferences=false - name: Build run: dotnet build --no-restore /p:UseProjectReferences=false /p:ContinuousIntegrationBuild=true - # Coverage is collected and ENFORCED here: each test project sets its own - # Threshold/ThresholdType and coverlet fails the run if coverage drops below it. - # A repo-wide number is not possible - coverage legitimately differs by an order - # of magnitude between a thin CLI entry point and a library. - # - # Linux only: the Codecov uploads below are Linux-only, so Windows coverage was - # computed and discarded - while coverlet.msbuild's GenerateCoverageResult has a - # timing race on Windows (it can read the hits file before the exiting test host - # finishes flushing it, failing the build with "Unable to read beyond the end of - # the stream" even though every test passed). The Windows leg's job is proving - # build + tests on Windows, so it runs without instrumentation. + # Coverage is enforced per test project via its own coverlet threshold. Linux only: the + # uploads below are Linux-only, and coverlet races the exiting test host on Windows. - name: Test if: runner.os == 'Linux' run: dotnet test --no-build --verbosity normal /p:UseProjectReferences=false /p:CollectCoverage=true /p:CoverletOutputFormat=opencover @@ -53,9 +42,8 @@ jobs: if: runner.os == 'Windows' run: dotnet test --no-build --verbosity normal /p:UseProjectReferences=false - # Uploaded per product with its own flag. Without flags every product's coverage - # would merge into one repo-wide percentage, which is meaningless across a client - # library and a CLI. Each product added to the repo needs its own step here. + # One flag per product - a merged repo-wide percentage across a library and a CLI + # would be meaningless. A new product needs its own step here. - name: Codecov (aspnetcore) if: runner.os == 'Linux' uses: codecov/codecov-action@v7 @@ -106,15 +94,9 @@ jobs: project-references: name: Build against repo source runs-on: ubuntu-latest - # Early-warning leg (§7). The default `build` job compiles every product against the - # PUBLISHED Kontent.Ai.* packages, which is what consumers actually get. This one flips - # those to ProjectReferences so a breaking change in one product fails in the PR that - # makes it, rather than months later when a consumer next updates. - # - # Blocking. It ran non-blocking through the migration of all five products and stayed - # green, so it now fails the build like any other leg. Mark it required in branch - # protection too - removing continue-on-error fails the RUN, but only a required - # check blocks the MERGE. + # Early-warning leg: the `build` job compiles against published packages, this one against + # in-repo sources, so a sibling breaking change fails in the PR that makes it. Blocking - + # mark it required in branch protection, since only a required check blocks the merge. steps: - uses: actions/checkout@v7 with: @@ -145,16 +127,12 @@ jobs: uses: actions/setup-dotnet@v6 with: global-json-file: global.json - # Explicitly package mode: this job packs, and packing is only ever valid against the - # dependency floors in Directory.Packages.props. Directory.Build.targets refuses to pack - # in source mode outright; this keeps the job from depending on that refusal. + # Explicit package mode: packing is only ever valid against the declared floors. - name: Restore dependencies run: dotnet restore /p:UseProjectReferences=false - name: Build run: dotnet build --configuration Release --no-restore /p:UseProjectReferences=false /p:ContinuousIntegrationBuild=true - # Packs every product listed in eng/products.json and asserts each one produced - # the packages it claims to. Adding a product to products.json is enough; this - # step needs no edit. + # Driven by eng/products.json, so a new product needs no edit here. - name: Pack and validate every product shell: bash run: | @@ -223,8 +201,7 @@ jobs: run: dotnet restore /p:UseProjectReferences=false - name: Build run: dotnet build --no-restore /p:UseProjectReferences=false /p:ContinuousIntegrationBuild=true - # These tests are skipped in the normal run unless KONTENT_SDK_RUN_REDIS_TESTS is set, - # so this job is the only thing that exercises them. + # Skipped everywhere else unless KONTENT_SDK_RUN_REDIS_TESTS is set. - name: Redis integration tests run: dotnet test src/delivery/Kontent.Ai.Delivery.Tests/Kontent.Ai.Delivery.Tests.csproj --no-build --filter "FullyQualifiedName~RedisCacheIntegrationTests" /p:UseProjectReferences=false env: @@ -235,11 +212,8 @@ jobs: name: Package smoke tests runs-on: ubuntu-latest timeout-minutes: 30 - # Pack validation proves the .nupkg files exist and contain the expected paths. This job proves a - # consumer can actually install and use them, which is a different question: it packs into a local - # feed and restores from it, so a broken nuspec dependency, a missing target framework, an analyzer - # packed to the wrong path or a .NET tool with no working entry point fails here rather than after - # publishing. Each fixture covers a different PACKAGE SHAPE, because they fail in different ways. + # Pack validation proves the packages exist; this proves a consumer can install and use them, + # by restoring from a local feed. One fixture per package shape - they fail in different ways. env: SMOKE_VERSION: 0.0.0-smoke.${{ github.run_number }} SMOKE_FEED: ${{ github.workspace }}/artifacts/smoke-feed @@ -257,7 +231,6 @@ jobs: - name: Build run: dotnet build --configuration Release --no-restore /p:UseProjectReferences=false - # Same product list as pack validation, so a new product is covered by editing products.json only. - name: Pack into a local feed shell: bash run: | @@ -269,26 +242,24 @@ jobs: done ls -1 "$SMOKE_FEED" - # Plain class libraries: restore from the nuspec, then resolve each client through DI. + # Resolves each client through DI. - name: Library consumer working-directory: eng/smoke/library run: dotnet run --configuration Release /p:KontentVersion=${{ env.SMOKE_VERSION }} - # Analyzer package. Roslyn silently skips a generator packed to the wrong path or built against a - # newer compiler than the SDK provides, so this fixture only compiles if the generator ran. + # Roslyn silently skips a generator packed to the wrong path, so this fixture only + # compiles if the generator actually ran. - name: Source generator consumer working-directory: eng/smoke/sourcegen run: dotnet run --configuration Release /p:KontentVersion=${{ env.SMOKE_VERSION }} - # Web SDK package: starts a real host rather than just compiling. + # Starts a real host rather than just compiling. - name: ASP.NET Core consumer working-directory: eng/smoke/aspnetcore run: dotnet run --configuration Release /p:KontentVersion=${{ env.SMOKE_VERSION }} - # .NET tool: install it the way a user would, then run it. The tool exits non-zero without an - # environment id, so success is asserted on its OWN validation message appearing - that only - # happens if the package layout, entry point and runtime resolution all worked. A tool that - # failed to install or launch produces a host error instead. + # Success is asserted on the tool's own validation message: a package that failed to + # install or launch produces a host error instead. - name: Model generator tool shell: bash run: | diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f5cc0e0e8..c0781bd24 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -10,29 +10,21 @@ on: schedule: - cron: '0 18 * * 1' - jobs: analyze: name: Analyze C# runs-on: ubuntu-latest - + steps: - name: Checkout repository uses: actions/checkout@v7 with: persist-credentials: false fetch-depth: 0 - - # Initializes the CodeQL tools for scanning. - # - # build-mode: none extracts C# straight from source, so this workflow no longer compiles - # the repo - ci.yml already builds it on two legs, and a third full build bought nothing. - # It is also what makes the run eligible for an overlay-base database, which is CodeQL's - # incremental cache for pull-request scans; without it every run built a full database. - # - # Trade-off: buildless extraction does not see generated code, so the output of - # Kontent.Ai.Delivery.SourceGeneration is not analyzed. That output is generated model - # records - no I/O, no user input, nothing the security queries would flag. + + # build-mode: none - ci.yml already builds the repo, and buildless extraction is what makes + # a run eligible for incremental overlay-base databases. It does not see generated code; + # the source generator only emits model records. - name: Initialize CodeQL uses: github/codeql-action/init@v4 with: diff --git a/.github/workflows/dependency-floors.yml b/.github/workflows/dependency-floors.yml index 6aa3efd1f..dccfac21f 100644 --- a/.github/workflows/dependency-floors.yml +++ b/.github/workflows/dependency-floors.yml @@ -1,17 +1,8 @@ name: Dependency floors -# Reports how far each cross-product dependency floor in Directory.Packages.props has fallen -# behind nuget.org. -# -# Not a CI step, deliberately. Drift is a function of TIME - of a sibling being released -# elsewhere - not of anything in a pull request's diff, so running it per PR would put the same -# unchanging notice on every unrelated review and query nuget.org on every push. And it must -# not gate a merge: a floor is meant to lag, and raising one forces every downstream consumer -# to upgrade. Reporting is the whole job. -# -# The report also rides along in every "Prepare release" PR, which is where the information is -# actually actionable. This scheduled run is the ambient one, so a floor cannot go stale -# indefinitely just because nobody prepared a release. +# Reports how far each cross-product floor in Directory.Packages.props lags nuget.org. +# Deliberately not a CI gate - a floor is meant to lag, and drift tracks time, not any PR's +# diff. Prepare release carries the same report; this is the ambient one. on: schedule: - cron: '0 6 1 * *' @@ -34,14 +25,11 @@ jobs: with: global-json-file: global.json - # Built first so that `dotnet run` below emits only the report. A cold build writes its - # diagnostics to stdout, which would otherwise land in the middle of the job summary. + # Built first so the run below emits only the report, not cold-build diagnostics. - name: Build the script run: dotnet build eng/scripts/dependency-floors.cs - # The script exits non-zero only when a floor names a version that is not on nuget.org - - # that breaks every restore in the repo, so it should fail loudly. Merely being behind - # exits 0. + # Non-zero only when a floor names a version missing from nuget.org. Merely lagging exits 0. - name: Report run: | set -euo pipefail diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 03e79e5b5..55a0a050f 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -1,14 +1,7 @@ name: Prepare release -# Bumps one or more products' versions and promotes their changelogs, then opens a single PR. -# Run it from Actions -> Prepare release -> Run workflow. -# -# Every product has its own dropdown, defaulting to `none`. Pick a bump for each product you -# want in this batch and leave the rest alone; one PR covers all of them. -# -# This does NOT publish anything. Merging the PR, then creating one GitHub Release per product -# tagged -v, is what triggers release.yml. Releases stay separate so a -# product can be published or abandoned without affecting the others. +# Bumps the selected products' versions and promotes their changelogs, then opens one PR. +# Publishes nothing - Publish batch does that once this PR is merged. on: workflow_dispatch: inputs: @@ -58,8 +51,7 @@ on: type: string required: false -# Two concurrent runs would open competing version PRs from the same base. Queue rather than -# cancel: a cancelled run can leave a pushed branch behind with no PR. +# Queue rather than cancel: a cancelled run can leave a pushed branch behind with no PR. concurrency: group: prepare-release cancel-in-progress: false @@ -79,8 +71,7 @@ jobs: with: global-json-file: global.json - # Inputs are user-supplied strings, so they are passed as environment variables and - # quoted - never interpolated into the shell command itself. + # Inputs are user-supplied: passed as quoted env vars, never interpolated into the shell. - name: Bump versions and promote changelogs id: bump env: @@ -141,11 +132,8 @@ jobs: echo "Prepared:" cat /tmp/summary.txt - # Folded into the PR body below. Preparing a release is the moment someone is already - # deciding what version each product should be on, so it is the one place a stale - # cross-product floor is actually actionable. Never fatal here: a floor is meant to lag, - # and the scheduled Dependency floors workflow is what fails loudly if one names a - # version that is not on NuGet at all. + # Folded into the PR body - the one place a stale floor is actionable. Never fatal: + # the scheduled Dependency floors workflow is what fails on a truly broken floor. - name: Report cross-product dependency floors run: | set -uo pipefail @@ -160,8 +148,7 @@ jobs: run: | set -euo pipefail - # One branch per batch. Dated rather than versioned: a batch has no single version, - # and the run number keeps same-day batches distinct. + # Dated rather than versioned: a batch has no single version. BRANCH="release/batch-$(date -u +%Y-%m-%d)-${GITHUB_RUN_NUMBER}" git config user.name "github-actions[bot]" diff --git a/.github/workflows/publish-batch.yml b/.github/workflows/publish-batch.yml index 0b91a043a..04927ee63 100644 --- a/.github/workflows/publish-batch.yml +++ b/.github/workflows/publish-batch.yml @@ -1,15 +1,8 @@ name: Publish batch -# Creates a GitHub Release for every product whose version is prepared but not yet on NuGet, -# in dependency order, waiting for each to actually publish before starting the next. -# -# Run it after merging a "Prepare release" PR. It is the counterpart to that workflow: one -# opens a single PR for many products, this one turns the merged result into many releases. -# -# It does not decide anything - the state of eng/Versions.props does. A product is released -# here only if its declared version is missing from nuget.org, so re-running is safe: already -# published products are skipped, and a product whose release exists but never published has -# that release recreated rather than colliding with it. +# Creates a GitHub Release for every product whose declared version is missing from nuget.org, +# in dependency order, waiting for each to publish before starting the next. Run it after +# merging a "Prepare release" PR. Re-running is safe - published products are skipped. on: workflow_dispatch: inputs: @@ -36,17 +29,8 @@ jobs: with: global-json-file: global.json - # A release created with the default GITHUB_TOKEN does NOT start release.yml: GitHub - # deliberately does not trigger workflows from events raised by that token, to stop - # workflows recursing. The release appears, nothing publishes, and this job would sit - # waiting for a package that is never pushed. - # - # An installation token from a GitHub App does trigger workflows, and unlike a personal - # access token it is short-lived and not tied to anyone's account. Requires a GitHub App - # with `contents: write` on this repository, installed on it, with its id and private key - # stored as the secrets below. - # - # Skipped on a dry run, so exploring the plan needs no credentials at all. + # GITHUB_TOKEN cannot raise workflow events, so a release it creates never starts + # release.yml. An app installation token does. Needs `contents: write` on this repo. - name: Mint a token that can trigger the release workflow id: app-token if: ${{ !inputs.dry_run }} @@ -60,22 +44,15 @@ jobs: run: | set -euo pipefail - # The batch, dependency-first, one product per line: + # Dependency-first, one product per line: # \t\t\t\t<"prerelease"|""> - # Ordering, cycle detection and the per-product lookups live in the script because it - # already has eng/products.json and eng/Versions.props open; doing them here meant - # serialising all of that to JSON and re-deriving it in jq. The script exits non-zero - # on a dependsOn cycle, naming the products involved. - # - # Kept out of a pipeline so a cycle (exit 1) fails the run, while an empty batch does not. + # Kept out of a pipeline so a dependsOn cycle (exit 1) fails the run, unlike an empty batch. if ! dotnet run eng/scripts/release-status.cs -- --order > /tmp/order.raw; then echo "::error::could not work out a release order - see the message above" exit 1 fi # `dotnet run` writes build diagnostics to stdout, so keep only well-formed rows. - # eng/scripts/Directory.Build.props keeps the scripts warning-free, but a workflow that - # publishes packages should not depend on that staying true. awk -F'\t' 'NF == 5' /tmp/order.raw > /tmp/order.tsv if [ ! -s /tmp/order.tsv ]; then @@ -95,9 +72,8 @@ jobs: run: | set -euo pipefail - # Wait for a product's packages to appear on nuget.org. The next release in the batch - # may depend on them, and release.yml refuses to publish against an unpublished - # cross-product dependency. + # release.yml refuses to publish against an unpublished cross-product dependency, and + # waiting also stops a failed release from being followed by its dependents. wait_for_publish() { product="$1" for _ in $(seq 1 60); do @@ -111,11 +87,8 @@ jobs: return 1 } - # Build release-notes.cs once, discarding the output. `dotnet run` prints build - # diagnostics to stdout only on a cold build, and unlike the JSON above these notes - # have no marker to filter on - contamination would be published into the release - # body rather than failing loudly. The warm-up run absorbs it; the real runs below - # reuse the cached build and emit nothing but the notes. + # Absorbs the cold build, whose stdout diagnostics would otherwise be published + # into a release body - these notes have no marker to filter on. dotnet run eng/scripts/release-notes.cs -- --warmup >/dev/null 2>&1 || true while IFS=$'\t' read -r product version tag title prerelease; do @@ -133,18 +106,9 @@ jobs: continue fi - # Reaching here means the product is pending, i.e. its packages are missing from NuGet. - # That is also exactly the state a failed release.yml leaves behind: release and tag - # created, nothing published. Calling `gh release create` again fails on the existing - # tag and, under `set -e`, takes the rest of the batch down with it - so "re-running is - # safe" was only true as long as nothing had gone wrong. - # - # The release is recreated rather than edited, because release.yml listens for - # `release: published` and only a draft becoming published raises it; editing a live - # release raises `edited` and would publish nothing, leaving the wait below to time - # out. Deleting the release leaves the tag alone, so the new one reuses it, and the - # notes are regenerated from the changelog either way. A partially published product - # is safe to retry: the push step passes --skip-duplicate. + # An existing release here means a previous release.yml failed. Recreated rather than + # edited, because release.yml only fires on `release: published`. Deleting keeps the + # tag, and retrying is safe - the push step passes --skip-duplicate. if gh release view "$tag" >/dev/null 2>&1; then echo " $tag exists but its packages are not on NuGet - recreating it to retry the publish" gh release delete "$tag" --yes diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab825af5c..d2aa2b829 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,18 +1,12 @@ name: Publish to NuGet -# Fires when a GitHub Release is published. The tag decides which product ships: -# aspnetcore-v0.17.0 -> packs only src/aspnetcore, publishes Kontent.Ai.AspNetCore -# Everything else in the repo is untouched. +# The tag decides what ships: aspnetcore-v0.17.0 packs only src/aspnetcore. on: release: types: [published] -# Scoped to the tag, NOT shared across releases. A repo-wide group would serialize releases, -# which sounds desirable for a batch - but GitHub keeps only one PENDING run per group and -# cancels older pending ones, so creating five releases at once would silently drop the middle -# three. Sequencing a batch is the dispatcher's job (publish-batch.yml), which waits for each -# release to finish before creating the next. Here the group only prevents the same tag from -# publishing twice concurrently, e.g. if a release is edited and re-published. +# Per tag, not repo-wide: GitHub keeps one pending run per group and cancels the rest, so a +# shared group would drop releases created together. publish-batch.yml sequences a batch. concurrency: group: release-${{ github.event.release.tag_name }} cancel-in-progress: false @@ -25,8 +19,8 @@ jobs: name: Pack and publish runs-on: ubuntu-latest permissions: - id-token: write # Required for OIDC token issuance - contents: write # Required for uploading release assets + id-token: write # OIDC + contents: write # upload release assets steps: - uses: actions/checkout@v7 with: @@ -36,26 +30,18 @@ jobs: with: global-json-file: global.json - # Resolves the tag into a product + project list, and refuses to continue if - # the tag disagrees with eng/Versions.props or the changelog has no entry. + # Refuses to continue if the tag disagrees with eng/Versions.props or the changelog is empty. - name: Resolve release plan id: plan run: dotnet run eng/scripts/release-plan.cs -- "$GITHUB_REF_NAME" - # UseProjectReferences=false is passed explicitly, not inherited from the default in - # Directory.Build.targets. This is the job that decides what the world installs: in - # source mode the generated nuspec would take sibling versions from eng/Versions.props - # instead of the declared floors in Directory.Packages.props, publishing a dependency - # range nobody asked for. Directory.Build.targets refuses to pack in source mode at - # all; this makes the intent explicit here rather than relying on that refusal. + # Explicit, not inherited: in source mode the nuspec would take sibling versions from + # eng/Versions.props instead of the declared floors. - name: Restore dependencies run: dotnet restore /p:UseProjectReferences=false - name: Build - # No /p:Version here. It is a GLOBAL property, so it would stamp the releasing - # product's version onto every project in the repo - and, once in-repo dependencies - # are ProjectReferences, onto sibling dependency versions in the generated nuspec. - # eng/Versions.props already carries the right version per product, and the - # "Resolve release plan" step above refuses to continue if it disagrees with the tag. + # No /p:Version - it is global and would stamp every project in the repo. + # eng/Versions.props already carries the right version per product. run: dotnet build --no-restore --configuration Release /p:UseProjectReferences=false /p:ContinuousIntegrationBuild=true - name: Pack @@ -88,15 +74,8 @@ jobs: echo "Produced instead:"; ls -la "$PACK_OUTPUT"; exit 1 fi - # Backstop for a mis-declared internal dependency: a package that depends on a - # Kontent.Ai.* version not yet on nuget.org would install for nobody. - # - # Packages within one product reference each other as ProjectReferences, so they pack as - # same-version dependencies (Kontent.Ai.Delivery 19.5.0 -> Kontent.Ai.Delivery.Abstractions - # 19.5.0). Those siblings are pushed by THIS run a step later, so asking nuget.org about - # them here would always fail. They are exempted; everything else must already be live. - # (nuget.org accepts a package whose dependencies are not yet indexed - dependencies are - # resolved at restore time - so publishing the batch together is safe.) + # A package depending on a Kontent.Ai.* version not yet on nuget.org installs for nobody. + # Same-version siblings are exempt: this run pushes them a step later. - name: Verify internal dependencies are published shell: bash env: @@ -105,7 +84,6 @@ jobs: run: | set -euo pipefail - # Is this dependency one of the packages this very run is about to publish? ships_in_this_release() { local id="$1" ver="$2" p [ "$ver" = "$RELEASE_VERSION" ] || return 1 @@ -115,8 +93,7 @@ jobs: return 1 } - # nuget.org indexing lags a little behind a push, so a correctly ordered - # dependency-first release can still look unpublished for a minute or two. + # nuget.org indexing lags a push by a minute or two. is_published() { local id="$1" ver="$2" url attempt url="https://api.nuget.org/v3-flatcontainer/$(echo "$id" | tr 'A-Z' 'a-z')/index.json" @@ -132,10 +109,8 @@ jobs: missing=0 for nupkg in "$PACK_OUTPUT"/*.nupkg; do echo "::group::$(basename "$nupkg")" - # `|| true` because grep exits 1 when a package declares no internal dependencies. - # That is the normal case for a base SDK (Kontent.Ai.Management, Kontent.Ai.Urls, - # both Abstractions packages, ...), not an error - and under `pipefail` it would - # otherwise fail the whole step. + # `|| true`: grep exits 1 when a package has no internal dependencies - the normal + # case for a base SDK, and fatal under `pipefail`. deps=$(unzip -p "$nupkg" '*.nuspec' \ | grep -oE '-v` (e.g. `management-v9.0.0`) packs and publishes only that product. It **refuses to publish** if the tag disagrees with `eng/Versions.props`, if the changelog has no entry, or if a cross-product `Kontent.Ai.*` dependency is not yet on nuget.org (same-release siblings exempt). - **`dependency-floors.yml`** (scheduled, monthly) — reports how far the cross-product floors lag nuget.org. Deliberately not a CI gate: a floor is *meant* to lag; raise it only when the consuming code needs the newer API. @@ -66,7 +66,7 @@ So the full release flow is: merge feature PRs (each user-visible change adds to - Commit messages: `TICKET-ID - Description` when a ticket exists (e.g. `EN-713 - Add component_types filter`); otherwise a concise lowercase summary matching branch history. Branch names: `TICKET-ID_Short_description`. - Keep each PR scoped: infra separate from per-product work; version bumps come only from the prepare-release workflow; floor raises in their own PR. -- Public API surface is gated per product by approval snapshots (Verify, printer shared from `src/testing`). Review a `.received.txt` diff line by line before accepting it — only for intended changes. +- Public API surface is gated per product by approval snapshots (Verify, printer shared from `src/testing`). Review a `.received.txt` diff line by line before accepting it — only for intended changes. Every shipped package has a gate except `Kontent.Ai.ModelGenerator`, which is `PackAsTool`: its contract is the command line, not a managed surface nobody references. Its arguments are covered by `ArgHelpers`/`Program` tests instead. ## Collaboration stance diff --git a/Directory.Build.targets b/Directory.Build.targets index 7b327987c..d4f81c7d9 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -1,32 +1,15 @@ + Evaluated inline rather than in a target, so restore sees the result. SourceGeneration + is absent on purpose - it is consumed as an analyzer, so swapping would change how it is + applied. Keep this list in step with eng/products.json. --> - + + Lagging is normal and intended: raise a floor only when the consuming code needs the + newer API, since raising it forces every downstream consumer to upgrade. --> true diff --git a/README.md b/README.md index e1a0fcfe4..2cf39cc66 100644 --- a/README.md +++ b/README.md @@ -7,48 +7,41 @@ # Kontent.ai .NET -> [!IMPORTANT] -> **This is the home of the Kontent.ai .NET SDKs.** All five products have moved here with -> their full history, and packages are published from this repository. -> -> The former per-product repositories are **frozen** — they are kept for reference and for -> the release history of versions published before the move. Open issues and pull requests -> here instead: -> -> | Former repository | Now at | -> |---|---| -> | [delivery-sdk-net](https://github.com/kontent-ai/delivery-sdk-net) | [`src/delivery`](./src/delivery) | -> | [management-sdk-net](https://github.com/kontent-ai/management-sdk-net) | [`src/management`](./src/management) | -> | [sync-sdk-net](https://github.com/kontent-ai/sync-sdk-net) | [`src/sync`](./src/sync) | -> | [aspnetcore-extensions](https://github.com/kontent-ai/aspnetcore-extensions) | [`src/aspnetcore`](./src/aspnetcore) | -> | [model-generator-net](https://github.com/kontent-ai/model-generator-net) | [`src/model-generator`](./src/model-generator) | -> -> Package IDs and public APIs are unchanged by the move. - -## About - -A monorepo for the Kontent.ai .NET SDKs and tooling — one place for the client libraries, -the model generator, integrations and samples, so that a change touching several of them -is one pull request rather than a coordinated release across five repositories. +A monorepo for the Kontent.ai .NET SDKs and tooling — the Delivery, Management and Sync +clients, the ASP.NET Core extensions and the model generator. Each keeps its own version, +changelog and release cadence, so a change touching several of them is one pull request +rather than a coordinated release across five repositories. + +| Product | Version | Readme | +|---|---|---| +| ASP.NET Core extensions | [![Kontent.Ai.AspNetCore][aspnetcore-nuget-shield]][aspnetcore-nuget-url] | [`src/aspnetcore/README.md`](./src/aspnetcore/README.md) | +| Delivery SDK | [![Kontent.Ai.Delivery][delivery-nuget-shield]][delivery-nuget-url] | [`src/delivery/README.md`](./src/delivery/README.md) | +| Management SDK | [![Kontent.Ai.Management][management-nuget-shield]][management-nuget-url] | [`src/management/README.md`](./src/management/README.md) | +| Model generator | [![Kontent.Ai.ModelGenerator][model-generator-nuget-shield]][model-generator-nuget-url] | [`src/model-generator/README.md`](./src/model-generator/README.md) | +| Sync SDK | [![Kontent.Ai.Sync][sync-nuget-shield]][sync-nuget-url] | [`src/sync/README.md`](./src/sync/README.md) | + +The badge tracks each product's flagship package on nuget.org, prereleases included, so it +shows the release candidates ahead of a GA rather than the stable line they supersede. + +> [!NOTE] +> This repository is where every Kontent.ai .NET SDK and tool is developed and published from. +> The former per-product repositories — [delivery-sdk-net](https://github.com/kontent-ai/delivery-sdk-net), +> [management-sdk-net](https://github.com/kontent-ai/management-sdk-net), +> [sync-sdk-net](https://github.com/kontent-ai/sync-sdk-net), +> [aspnetcore-extensions](https://github.com/kontent-ai/aspnetcore-extensions) and +> [model-generator-net](https://github.com/kontent-ai/model-generator-net) — are frozen and kept +> only for their earlier release history, so open issues and pull requests here. ## Layout ``` src// each product, with its own CHANGELOG.md and package metadata +src/common/ source compiled into the SDKs rather than shipped as a package +src/testing/ test infrastructure shared across products; ships nothing eng/ version source of truth, release routing, build scripts .github/workflows/ CI and the tag-routed release pipeline ``` -Currently migrated: - -| Product | Path | Packages | -|---|---|---| -| ASP.NET Core extensions | `src/aspnetcore` | `Kontent.Ai.AspNetCore` | -| Delivery SDK | `src/delivery` | `Kontent.Ai.Delivery`, `Kontent.Ai.Delivery.Abstractions`, `Kontent.Ai.Delivery.Caching`, `Kontent.Ai.Delivery.SourceGeneration`, `Kontent.Ai.Urls` | -| Management SDK | `src/management` | `Kontent.Ai.Management` | -| Model generator | `src/model-generator` | `Kontent.Ai.ModelGenerator`, `Kontent.Ai.ModelGenerator.Core` | -| Sync SDK | `src/sync` | `Kontent.Ai.Sync` | - ## Building Requires the .NET SDK pinned in [`global.json`](./global.json). @@ -164,3 +157,14 @@ Distributed under the MIT License. See [`LICENSE.md`](./LICENSE.md) for more inf [issues-url]: https://github.com/kontent-ai/dotnet/issues [license-shield]: https://img.shields.io/github/license/kontent-ai/dotnet.svg?style=for-the-badge [license-url]: https://github.com/kontent-ai/dotnet/blob/main/LICENSE.md + +[aspnetcore-nuget-shield]: https://img.shields.io/nuget/vpre/Kontent.Ai.AspNetCore +[aspnetcore-nuget-url]: https://www.nuget.org/packages/Kontent.Ai.AspNetCore +[delivery-nuget-shield]: https://img.shields.io/nuget/vpre/Kontent.Ai.Delivery +[delivery-nuget-url]: https://www.nuget.org/packages/Kontent.Ai.Delivery +[management-nuget-shield]: https://img.shields.io/nuget/vpre/Kontent.Ai.Management +[management-nuget-url]: https://www.nuget.org/packages/Kontent.Ai.Management +[model-generator-nuget-shield]: https://img.shields.io/nuget/vpre/Kontent.Ai.ModelGenerator +[model-generator-nuget-url]: https://www.nuget.org/packages/Kontent.Ai.ModelGenerator +[sync-nuget-shield]: https://img.shields.io/nuget/vpre/Kontent.Ai.Sync +[sync-nuget-url]: https://www.nuget.org/packages/Kontent.Ai.Sync diff --git a/codecov.yml b/codecov.yml index 1fc662cc3..33f97a3aa 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,17 +1,8 @@ -# Codecov reports, it does not gate. +# Codecov reports, it does not gate - each test project's coverlet threshold is the real gate, +# enforced by `dotnet test` in ci.yml. Codecov's default `target: auto` would additionally fail a +# pure refactor that shifts the ratio by a fraction of a percent. # -# Coverage is already enforced where it can be judged properly: each test project sets its own -# coverlet Threshold/ThresholdType/ThresholdStat, and `dotnet test` in ci.yml fails the run if the -# product drops below it. Those numbers differ by an order of magnitude between a thin CLI entry -# point and a client library, so they are chosen per product rather than repo-wide. -# -# Codecov's default status is `target: auto`, meaning "must not fall below the base commit by any -# amount". Layered on top of a real gate, that turns a 0.2% ratio shift — which a pure refactor can -# cause by deleting covered lines — into a red check on an otherwise healthy PR. `informational` -# keeps the report, the diff annotations and the PR comment, without the veto. -# -# If a coverage floor should move, move the coverlet threshold in the relevant test project. That -# is a deliberate, reviewable decision; a drifting auto-target is not. +# To move a coverage floor, move the coverlet threshold in the relevant test project. coverage: status: diff --git a/eng/Versions.props b/eng/Versions.props index 1c1ded0a1..f56c9d654 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -1,15 +1,11 @@ - + - 20.0.0-rc.1 - 2.0.0-rc.1 - 9.0.0-rc.1 + 20.0.0-rc.2 + 2.0.0-rc.2 + 9.0.0-rc.2 1.0.0-rc.1 11.0.0-rc.1 diff --git a/eng/scripts/Directory.Build.props b/eng/scripts/Directory.Build.props index 7cf130e87..5e09430ff 100644 --- a/eng/scripts/Directory.Build.props +++ b/eng/scripts/Directory.Build.props @@ -1,13 +1,8 @@ - + latest enable diff --git a/eng/scripts/dependency-floors.cs b/eng/scripts/dependency-floors.cs index 7e0731924..7a8c31bee 100644 --- a/eng/scripts/dependency-floors.cs +++ b/eng/scripts/dependency-floors.cs @@ -1,26 +1,14 @@ -// Reports how far each cross-product dependency floor in Directory.Packages.props has fallen -// behind what is actually published on nuget.org. +// Reports how far each cross-product floor in Directory.Packages.props lags nuget.org. // -// dotnet run eng/scripts/dependency-floors.cs -// dotnet run eng/scripts/dependency-floors.cs -- --json +// dotnet run eng/scripts/dependency-floors.cs [-- --json] // -// Why this exists: releasing a product does not touch the version its siblings depend on. -// Those floors are raised deliberately, in their own PR, after the dependency is published - -// see CONTRIBUTING.md, "Changing an API that another product consumes". Nothing else in the -// repo notices when one goes stale, so without this the gap is invisible until someone -// happens to look. +// Lagging is NOT an error - a floor is a minimum, and raising it forces every downstream +// consumer to upgrade, so this reports and leaves the decision to a human. Exits non-zero only +// when a floor names a version not on nuget.org at all, which breaks every restore in the repo. // -// Lagging is NOT an error. A floor is the minimum version a published package promises to -// work with, and raising it forces every downstream consumer to upgrade too. This reports the -// gap and leaves the decision to a human. It exits non-zero only when a floor names a version -// that is not on nuget.org at all - that one is a genuine breakage, because every restore in -// the repo resolves it, including the root restore in release.yml. -// -// Ordering is nuget.org's own: the flat-container index lists versions in SemVer order, so -// "newer" here means "listed after the floor". One caveat that scheme carries - a prerelease -// label like `beta-5` is a single alphanumeric SemVer identifier, compared lexically rather -// than numerically, so `9.0.0-beta-10` sorts BEFORE `9.0.0-beta-5`. The out-of-order pass -// below catches that case rather than silently under-reporting it. +// Ordering is nuget.org's own SemVer order, in which `beta-5` is a single alphanumeric +// identifier compared as text - so `9.0.0-beta-10` sorts BEFORE `9.0.0-beta-5`. The +// out-of-order pass below catches that rather than under-reporting it. using System.Text.Json; using System.Text.RegularExpressions; diff --git a/eng/scripts/release-notes.cs b/eng/scripts/release-notes.cs index 29b40e3d4..26c4ffa32 100644 --- a/eng/scripts/release-notes.cs +++ b/eng/scripts/release-notes.cs @@ -1,16 +1,9 @@ -// Prints the GitHub Release notes for a tag, taken from that product's CHANGELOG.md. +// Reshapes one CHANGELOG.md entry into GitHub Release notes: drops the version heading, +// promotes the entry's "###" sections to "##", appends an install snippet. // // dotnet run eng/scripts/release-notes.cs -- management-v9.0.0-beta-5 -// dotnet run eng/scripts/release-notes.cs -- delivery-v19.5.0 > notes.md // -// The changelog is the source of truth; this just reshapes one entry for the release page: -// -// * drops the "## ()" heading - the release page already shows title and tag -// * promotes the entry's own "###" sections to "##" so they read as top level -// * appends an install snippet, with --prerelease when the version is a prerelease -// -// Exits non-zero if the tag does not parse, the product is unknown, or the changelog has no -// entry for that version - the same conditions release-plan.cs refuses to publish on. +// Exits non-zero on the same conditions release-plan.cs refuses to publish on. using System.Text; using System.Text.Json; diff --git a/eng/scripts/release-plan.cs b/eng/scripts/release-plan.cs index b527a7917..8be040b4f 100644 --- a/eng/scripts/release-plan.cs +++ b/eng/scripts/release-plan.cs @@ -1,18 +1,11 @@ -// Resolves a release tag into a concrete publish plan, and refuses to proceed if the -// repository disagrees with the tag. +// Resolves a release tag into a publish plan, failing the release rather than publishing +// something wrong if the product is unknown, the version disagrees with eng/Versions.props, or +// the changelog has no "## " heading. // // dotnet run eng/scripts/release-plan.cs -- aspnetcore-v0.17.0 // -// Tag format is -v. The "-v" separator is required, not cosmetic: -// product names and prerelease versions both contain hyphens (model-generator, -// 9.0.0-beta-4), so a bare "-" cannot be split back into product and version. -// -// Checks, all of which fail the release rather than publish something wrong: -// 1. tag parses, and the product exists in eng/products.json -// 2. the version in the tag equals the product's property in eng/Versions.props -// 3. the product's CHANGELOG.md has a "## " heading -// -// Writes product/version/projects/packages to $GITHUB_OUTPUT when running in Actions. +// The "-v" separator is required, not cosmetic: product names and prerelease versions both +// contain hyphens (model-generator, 9.0.0-beta-4). Writes outputs to $GITHUB_OUTPUT in Actions. using System.Text.Json; using System.Text.RegularExpressions; diff --git a/eng/scripts/release-status.cs b/eng/scripts/release-status.cs index 42e90a5eb..5bd01eaa5 100644 --- a/eng/scripts/release-status.cs +++ b/eng/scripts/release-status.cs @@ -1,28 +1,12 @@ -// Reports, per product, whether the version currently declared in eng/Versions.props has -// actually been published to nuget.org. +// Reports, per product, whether the version declared in eng/Versions.props is on nuget.org. // -// dotnet run eng/scripts/release-status.cs -// dotnet run eng/scripts/release-status.cs -- --json -// dotnet run eng/scripts/release-status.cs -- --order -// dotnet run eng/scripts/release-status.cs -- --is-published +// dotnet run eng/scripts/release-status.cs [-- --json | --order | --is-published ] // -// Why this exists: preparing a release and publishing it are two separate steps. A batch can -// bump three products and then only two get released, leaving the third with a bumped version -// property and a dated changelog entry but nothing on NuGet. That state is legitimate - it just -// means "not yet" - but it is invisible, and preparing again would silently skip the version. -// -// Informational by design: the reporting modes always exit 0 unless they cannot do the job (bad -// repo, network failure). A prepared-but-unpublished version is a normal intermediate state, not -// an error. --order is the exception: it fails on a dependsOn cycle, because a batch it cannot -// order is a batch that must not run. -// -// --order emits the batch publish-batch.yml works through, one product per line, dependencies -// first, tab-separated: +// Prepared-but-unpublished is a legitimate but invisible state, so the reporting modes exit 0 +// unless they cannot do the job. --order is the exception: it fails on a dependsOn cycle. // +// --order emits the batch publish-batch.yml works through, dependencies first, tab-separated: // \t\t\t\t<"prerelease"|""> -// -// It lives here rather than in the workflow because the inputs are already open: this script -// reads eng/products.json and eng/Versions.props, and knows which versions are still pending. using System.Text.Json; using System.Text.RegularExpressions; diff --git a/eng/scripts/update-version.cs b/eng/scripts/update-version.cs index 5efac61e4..3544a7a72 100644 --- a/eng/scripts/update-version.cs +++ b/eng/scripts/update-version.cs @@ -1,14 +1,8 @@ // Bumps a product's version in eng/Versions.props and promotes its changelog's -// "## Unreleased" section to the new version. +// "## Unreleased" section to the new version. Stages everything; commits nothing. // // dotnet run eng/scripts/update-version.cs -- -// -// Examples: -// dotnet run eng/scripts/update-version.cs -- aspnetcore minor // dotnet run eng/scripts/update-version.cs -- aspnetcore 1.0.0-preview.1 -// -// Leaves everything staged for review; commits nothing. A file-based app (.NET 10) -// so there is no project to maintain and no extra toolchain to install. using System.Text.Json; using System.Text.RegularExpressions; @@ -226,19 +220,13 @@ static int CompareSemVer(string a, string b) } } -// SemVer compares a prerelease label as dot-separated identifiers: numeric ones numerically, -// everything else as text. `beta-5` is ONE alphanumeric identifier, so it is compared as text - -// 9.0.0-beta-10 sorts BEFORE 9.0.0-beta-5, and nuget.org stops offering the newer prerelease as -// the latest. `preview.1` splits into `preview` + `1` and compares numerically, so it is safe. -// -// `warnFrom` is 1 when the label is being chosen fresh and 9 when an existing sequence is being -// continued. Continuing is the case where the advice is nearly useless - a product already on -// `-N` labels cannot switch mid-line, because 9.0.0-rc.1 would sort BEFORE 9.0.0-beta-5 - so -// there is no point saying anything at beta-2 and every point in saying it at beta-9. Choosing a -// label is the opposite: that is the one moment the shape is still free, so say it immediately. +// `beta-5` is ONE alphanumeric SemVer identifier, compared as text - so 9.0.0-beta-10 sorts +// BEFORE 9.0.0-beta-5 and nuget.org stops offering the newer prerelease. `preview.1` splits into +// `preview` + `1` and compares numerically, so it is safe. // -// A warning rather than an error throughout: the labels already shipped are valid, and the -// escape (a new label, or GA) is a judgement call about the release, not about this command. +// warnFrom is 1 when a label is being chosen fresh - the one moment the shape is still free - +// and 9 when a sequence is being continued, where the advice is nearly useless. A warning rather +// than an error: the escape is a judgement call about the release, not about this command. static void WarnIfPrereleaseWillMisSort(string version, int warnFrom) { var dash = version.IndexOf('-'); diff --git a/src/aspnetcore/CHANGELOG.md b/src/aspnetcore/CHANGELOG.md index 7100ec9fc..12ce37be2 100644 --- a/src/aspnetcore/CHANGELOG.md +++ b/src/aspnetcore/CHANGELOG.md @@ -6,6 +6,23 @@ Entries before the move to this monorepo were imported from the GitHub Releases ## Unreleased +### Breaking changes + +- **`WebhookNotification.Notifications` is `IReadOnlyList?` instead of `WebhookModel[]?`.** `WebhookNotification` is a record, so it compares by value — except that an array member compares by reference, which meant two notifications carrying identical payloads were never equal. The array was also handed out mutable, so a caller could rewrite a deserialized payload in place. Reading the collection is unaffected: indexing, `foreach`, `Count` (rather than `Length`) and LINQ all work as before. Code that assigned an array to the property still compiles; code that declared the receiving variable as `WebhookModel[]` needs `IReadOnlyList` or `var`. + +### Changed + +- **The modern signature header now wins when a request carries both.** `X-Kontent-ai-Signature` is read first and `X-KC-Signature` is the fallback, rather than the other way round. Both were always verified against the same secret, so this is not a security change — a request with only one header behaves exactly as before. It only settles which is authoritative when both are present, and it matches how the README and the header names themselves present the two. + +### Fixed + +- **`RichTextTagHelper` uses a primary constructor**, matching the other tag helpers in the package. + +- **A null `predicate` passed to `UseWebhookSignatureValidator` is rejected at registration.** Every other argument on those overloads was guarded; this one was dereferenced later by `UseWhen`, so the mistake surfaced away from the call that made it. + +- **The webhook signature is computed over the bytes as received.** The body was decoded to a string and re-encoded before hashing. The decoder substitutes replacement characters for malformed input rather than failing, so that round trip could map two different request bodies onto the same bytes — and a comment claimed the opposite property, that a body which is not valid UTF-8 could not hash like one that is. Verification is fail-closed either way, so no invalid signature was ever accepted; the round trip and the comment are both gone. + + ## 1.0.0-rc.1 (2026-08-07) _(prerelease)_ Targets .NET 10, moving from `net8.0` to `net10.0`. Webhook signature verification is hardened in two ways diff --git a/src/aspnetcore/Kontent.Ai.AspNetCore.Tests/ApiApproval/PublicApiApprovalTests.PublicApi_ShouldNotChangeUnexpectedly.verified.txt b/src/aspnetcore/Kontent.Ai.AspNetCore.Tests/ApiApproval/PublicApiApprovalTests.PublicApi_ShouldNotChangeUnexpectedly.verified.txt index 4055ee185..5eec11bab 100644 --- a/src/aspnetcore/Kontent.Ai.AspNetCore.Tests/ApiApproval/PublicApiApprovalTests.PublicApi_ShouldNotChangeUnexpectedly.verified.txt +++ b/src/aspnetcore/Kontent.Ai.AspNetCore.Tests/ApiApproval/PublicApiApprovalTests.PublicApi_ShouldNotChangeUnexpectedly.verified.txt @@ -27,7 +27,7 @@ public sealed class MediaConditionTagHelper : TagHelper Task ProcessAsync(TagHelperContext context, TagHelperOutput output) // Kontent.Ai.AspNetCore.RichText -public sealed class RichTextExtensions +public static class RichTextExtensions static Task ToHtmlContentAsync(IRichTextContent richText, IHtmlResolver? resolver, CancellationToken cancellationToken) // Kontent.Ai.AspNetCore.RichText @@ -38,12 +38,12 @@ public sealed class RichTextTagHelper : TagHelper Task ProcessAsync(TagHelperContext context, TagHelperOutput output) // Kontent.Ai.AspNetCore.RichText -public sealed class ServiceCollectionExtensions +public static class ServiceCollectionExtensions static IServiceCollection AddKontentRichText(IServiceCollection services, Action? configure) static IServiceCollection AddKontentRichText(IServiceCollection services, Action? configure) // Kontent.Ai.AspNetCore.Webhooks -public sealed class ApplicationBuilderExtensions +public static class ApplicationBuilderExtensions static IApplicationBuilder UseWebhookSignatureValidator(IApplicationBuilder app, Func predicate) static IApplicationBuilder UseWebhookSignatureValidator(IApplicationBuilder app, Func predicate, Action configureOptions) static IApplicationBuilder UseWebhookSignatureValidator(IApplicationBuilder app, Func predicate, IConfigurationSection configurationSection) @@ -114,7 +114,7 @@ public sealed class WebhookModel : IEquatable // Kontent.Ai.AspNetCore.Webhooks.Models public sealed class WebhookNotification : IEquatable .ctor() - WebhookModel[]? Notifications { get; init; } + IReadOnlyList? Notifications { get; init; } Boolean Equals(Object? obj) Boolean Equals(WebhookNotification? other) Int32 GetHashCode() diff --git a/src/aspnetcore/Kontent.Ai.AspNetCore.Tests/SignatureMiddlewareTests.cs b/src/aspnetcore/Kontent.Ai.AspNetCore.Tests/SignatureMiddlewareTests.cs index ab2b61fb6..0581f2fc3 100644 --- a/src/aspnetcore/Kontent.Ai.AspNetCore.Tests/SignatureMiddlewareTests.cs +++ b/src/aspnetcore/Kontent.Ai.AspNetCore.Tests/SignatureMiddlewareTests.cs @@ -123,7 +123,8 @@ public async Task RequestWithValidSignature_BodyRemainsReadableDownstream() [Fact] public async Task ModernSignatureHeaderTakesPrecedenceOverLegacy() { - // Middleware prefers X-KC-Signature if present; X-Kontent-ai-Signature is the fallback. + // The modern header is read when both are present; the legacy one is only a fallback. A valid + // signature in the legacy header must not rescue a request whose modern header is wrong. var nextCalled = false; RequestDelegate next = _ => { @@ -135,8 +136,8 @@ public async Task ModernSignatureHeaderTakesPrecedenceOverLegacy() var validSignature = ComputeHmacSha256(body, Secret); var ctx = CreateHttpContext(body); - ctx.Request.Headers.Append("X-KC-Signature", validSignature); - ctx.Request.Headers.Append("X-Kontent-ai-Signature", "invalid-fallback"); + ctx.Request.Headers.Append("X-Kontent-ai-Signature", validSignature); + ctx.Request.Headers.Append("X-KC-Signature", "invalid-legacy"); var middleware = new SignatureMiddleware(next, Options.Create(new WebhookOptions { Secret = Secret })); await middleware.InvokeAsync(ctx); @@ -144,6 +145,29 @@ public async Task ModernSignatureHeaderTakesPrecedenceOverLegacy() Assert.True(nextCalled); } + [Fact] + public async Task LegacySignatureHeader_DoesNotRescueAnInvalidModernHeader() + { + var nextCalled = false; + RequestDelegate next = _ => + { + nextCalled = true; + return Task.CompletedTask; + }; + + const string body = "payload"; + + var ctx = CreateHttpContext(body); + ctx.Request.Headers.Append("X-Kontent-ai-Signature", "invalid-modern"); + ctx.Request.Headers.Append("X-KC-Signature", ComputeHmacSha256(body, Secret)); + + var middleware = new SignatureMiddleware(next, Options.Create(new WebhookOptions { Secret = Secret })); + await middleware.InvokeAsync(ctx); + + Assert.False(nextCalled); + Assert.Equal(StatusCodes.Status401Unauthorized, ctx.Response.StatusCode); + } + private static DefaultHttpContext CreateHttpContext(string body, string? headerName = null, string? headerValue = null) { var ctx = new DefaultHttpContext(); diff --git a/src/aspnetcore/Kontent.Ai.AspNetCore.Tests/WebhookDeserializationTests.cs b/src/aspnetcore/Kontent.Ai.AspNetCore.Tests/WebhookNotificationTests.cs similarity index 100% rename from src/aspnetcore/Kontent.Ai.AspNetCore.Tests/WebhookDeserializationTests.cs rename to src/aspnetcore/Kontent.Ai.AspNetCore.Tests/WebhookNotificationTests.cs diff --git a/src/aspnetcore/Kontent.Ai.AspNetCore.Tests/WebhookSignatureValidatorPipelineTests.cs b/src/aspnetcore/Kontent.Ai.AspNetCore.Tests/WebhookSignatureValidatorPipelineTests.cs new file mode 100644 index 000000000..342db9b6c --- /dev/null +++ b/src/aspnetcore/Kontent.Ai.AspNetCore.Tests/WebhookSignatureValidatorPipelineTests.cs @@ -0,0 +1,102 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using Kontent.Ai.AspNetCore.Webhooks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Kontent.Ai.AspNetCore.Tests; + +/// +/// The overload that takes its from the container resolves them per request, +/// so a host that never configured them looks fine until the first webhook arrives. Exercised through a +/// real pipeline rather than by calling the middleware directly, because what is under test is the +/// registration - that the branch is taken and the options are found. +/// +public class WebhookSignatureValidatorPipelineTests +{ + private const string Secret = "test-secret"; + private const string Body = "payload"; + + [Fact] + public async Task ContainerResolvedOptions_ValidSignature_ReachesTheEndpoint() + { + using var host = await StartHostAsync(configureOptions: true); + + var response = await SendAsync(host, Signature(Body, Secret)); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("handled", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task ContainerResolvedOptions_InvalidSignature_IsRejected() + { + using var host = await StartHostAsync(configureOptions: true); + + var response = await SendAsync(host, "not-the-signature"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task ContainerResolvedOptions_NeverConfigured_FailsAtTheFirstRequest() + { + using var host = await StartHostAsync(configureOptions: false); + + var act = async () => await SendAsync(host, Signature(Body, Secret)); + + var exception = await Assert.ThrowsAsync(act); + Assert.Contains(nameof(WebhookOptions.Secret), exception.Message); + } + + [Fact] + public async Task ContainerResolvedOptions_PathOutsideThePredicate_IsNotValidated() + { + using var host = await StartHostAsync(configureOptions: true); + + var response = await host.GetTestClient().PostAsync("/elsewhere", new StringContent(Body)); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + private static async Task StartHostAsync(bool configureOptions) + { + var host = await new HostBuilder() + .ConfigureWebHost(webHost => webHost + .UseTestServer() + .ConfigureServices(services => + { + if (configureOptions) + { + services.Configure(options => options.Secret = Secret); + } + }) + .Configure(app => + { + app.UseWebhookSignatureValidator(context => context.Request.Path.StartsWithSegments("/webhook")); + app.Run(context => context.Response.WriteAsync("handled")); + })) + .StartAsync(); + + return host; + } + + private static Task SendAsync(IHost host, string signature) + { + var request = new HttpRequestMessage(HttpMethod.Post, "/webhook") + { + Content = new StringContent(Body), + }; + request.Headers.Add("X-Kontent-ai-Signature", signature); + + return host.GetTestClient().SendAsync(request); + } + + private static string Signature(string body, string secret) => + Convert.ToBase64String(HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(body))); +} diff --git a/src/aspnetcore/Kontent.Ai.AspNetCore/ImageTransformation/AssetTagHelper.cs b/src/aspnetcore/Kontent.Ai.AspNetCore/ImageTransformation/AssetTagHelper.cs index 204b1d84a..7ad180ed9 100644 --- a/src/aspnetcore/Kontent.Ai.AspNetCore/ImageTransformation/AssetTagHelper.cs +++ b/src/aspnetcore/Kontent.Ai.AspNetCore/ImageTransformation/AssetTagHelper.cs @@ -124,7 +124,7 @@ public override async Task ProcessAsync(TagHelperContext context, TagHelperOutpu context.Items.Add(SizesCollection, sizes); await output.GetChildContentAsync(); - var s = string.Join(", ", sizes.Concat(new[] { $"{DefaultWidth}px" })); + var s = string.Join(", ", sizes.Concat([$"{DefaultWidth}px"])); image.MergeAttribute("sizes", s); // Fallback src for clients that don't honor srcset — use the largest declared width. diff --git a/src/aspnetcore/Kontent.Ai.AspNetCore/RichText/RichTextTagHelper.cs b/src/aspnetcore/Kontent.Ai.AspNetCore/RichText/RichTextTagHelper.cs index ac9b2dced..3ff16423c 100644 --- a/src/aspnetcore/Kontent.Ai.AspNetCore/RichText/RichTextTagHelper.cs +++ b/src/aspnetcore/Kontent.Ai.AspNetCore/RichText/RichTextTagHelper.cs @@ -16,11 +16,10 @@ namespace Kontent.Ai.AspNetCore.RichText; /// /// The <rich-text> element itself is not emitted — the resolver's HTML is rendered in its place. /// +/// Optional resolver injected from the DI container via AddKontentRichText. [HtmlTargetElement("rich-text", Attributes = "content")] -public sealed class RichTextTagHelper : TagHelper +public sealed class RichTextTagHelper(IHtmlResolver? defaultResolver = null) : TagHelper { - private readonly IHtmlResolver? _defaultResolver; - /// /// The structured rich-text content to render. /// @@ -33,15 +32,6 @@ public sealed class RichTextTagHelper : TagHelper [HtmlAttributeName("resolver")] public IHtmlResolver? Resolver { get; set; } - /// - /// Creates an instance of the . - /// - /// Optional resolver injected from the DI container via AddKontentRichText. - public RichTextTagHelper(IHtmlResolver? defaultResolver = null) - { - _defaultResolver = defaultResolver; - } - /// public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output) { @@ -52,7 +42,7 @@ public override async Task ProcessAsync(TagHelperContext context, TagHelperOutpu return; } - var resolver = Resolver ?? _defaultResolver ?? new HtmlResolverBuilder().Build(); + var resolver = Resolver ?? defaultResolver ?? new HtmlResolverBuilder().Build(); var html = await resolver.ResolveAsync(Content); output.Content.SetHtmlContent(html); } diff --git a/src/aspnetcore/Kontent.Ai.AspNetCore/Webhooks/ApplicationBuilderExtensions.cs b/src/aspnetcore/Kontent.Ai.AspNetCore/Webhooks/ApplicationBuilderExtensions.cs index 2a53ed663..2e5a97f4c 100644 --- a/src/aspnetcore/Kontent.Ai.AspNetCore/Webhooks/ApplicationBuilderExtensions.cs +++ b/src/aspnetcore/Kontent.Ai.AspNetCore/Webhooks/ApplicationBuilderExtensions.cs @@ -20,6 +20,7 @@ public static class ApplicationBuilderExtensions public static IApplicationBuilder UseWebhookSignatureValidator(this IApplicationBuilder app, Func predicate) { ArgumentNullException.ThrowIfNull(app); + ArgumentNullException.ThrowIfNull(predicate); app.UseWhen(predicate, appBuilder => appBuilder.UseMiddleware()); @@ -36,6 +37,7 @@ public static IApplicationBuilder UseWebhookSignatureValidator(this IApplication public static IApplicationBuilder UseWebhookSignatureValidator(this IApplicationBuilder app, Func predicate, WebhookOptions options) { ArgumentNullException.ThrowIfNull(app); + ArgumentNullException.ThrowIfNull(predicate); ArgumentNullException.ThrowIfNull(options); app.UseWhen(predicate, appBuilder => appBuilder.UseMiddleware(Options.Create(options))); diff --git a/src/aspnetcore/Kontent.Ai.AspNetCore/Webhooks/Models/WebhookNotification.cs b/src/aspnetcore/Kontent.Ai.AspNetCore/Webhooks/Models/WebhookNotification.cs index bcb0fa95c..a97877aea 100644 --- a/src/aspnetcore/Kontent.Ai.AspNetCore/Webhooks/Models/WebhookNotification.cs +++ b/src/aspnetcore/Kontent.Ai.AspNetCore/Webhooks/Models/WebhookNotification.cs @@ -12,7 +12,7 @@ public sealed record WebhookNotification /// A collection of webhook notifications for each modified object. /// [JsonPropertyName("notifications"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public WebhookModel[]? Notifications { get; init; } + public IReadOnlyList? Notifications { get; init; } } /// diff --git a/src/aspnetcore/Kontent.Ai.AspNetCore/Webhooks/SignatureMiddleware.cs b/src/aspnetcore/Kontent.Ai.AspNetCore/Webhooks/SignatureMiddleware.cs index 88b079c7f..4016f1376 100644 --- a/src/aspnetcore/Kontent.Ai.AspNetCore/Webhooks/SignatureMiddleware.cs +++ b/src/aspnetcore/Kontent.Ai.AspNetCore/Webhooks/SignatureMiddleware.cs @@ -22,7 +22,7 @@ public sealed class SignatureMiddleware(RequestDelegate next, IOptions /// HTTP context whose request to inspect. - /// + /// A task that completes when the request has been handled or rejected. /// /// is not configured. Without it no signature can be /// verified, and continuing would admit unsigned requests. @@ -40,11 +40,15 @@ public async Task InvokeAsync(HttpContext httpContext) var request = httpContext.Request; request.EnableBuffering(); - string content; + // The signature covers the bytes the sender signed, so they are hashed as received. Decoding to a + // string and re-encoding would put a lossy step in the middle: the decoder substitutes replacement + // characters for malformed input, so two different bodies can re-encode to the same bytes. + byte[] content; try { - using var reader = new StreamReader(request.Body, Encoding.UTF8, true, 1024, true); - content = await reader.ReadToEndAsync(httpContext.RequestAborted); + using var buffer = new MemoryStream(); + await request.Body.CopyToAsync(buffer, httpContext.RequestAborted); + content = buffer.ToArray(); } finally { @@ -56,8 +60,11 @@ public async Task InvokeAsync(HttpContext httpContext) } } - var providedSignature = request.Headers["X-KC-Signature"].FirstOrDefault() - ?? request.Headers["X-Kontent-ai-Signature"].FirstOrDefault(); + // Modern header first; the legacy one is the fallback for webhooks configured before the rename. + // Both are verified against the same secret, so precedence only decides which is read when a + // request carries both - but it is observable, so it is stated rather than incidental. + var providedSignature = request.Headers["X-Kontent-ai-Signature"].FirstOrDefault() + ?? request.Headers["X-KC-Signature"].FirstOrDefault(); if (!SignatureMatches(content, secret, providedSignature)) { @@ -78,7 +85,7 @@ public async Task InvokeAsync(HttpContext httpContext) /// time. Length carries no secret here: an HMAC-SHA256 digest is always the same size, so a signature /// that does not decode to exactly that many bytes is rejected outright. /// - private static bool SignatureMatches(string content, string secret, string? providedSignature) + private static bool SignatureMatches(ReadOnlySpan content, string secret, string? providedSignature) { if (providedSignature is null) { @@ -92,12 +99,8 @@ private static bool SignatureMatches(string content, string secret, string? prov return false; } - // Throws on malformed input rather than substituting replacement characters, so a body that is - // not valid UTF-8 cannot hash to the same value as one that is. - var encoding = new UTF8Encoding(false, true); - Span expected = stackalloc byte[HMACSHA256.HashSizeInBytes]; - HMACSHA256.HashData(encoding.GetBytes(secret), encoding.GetBytes(content), expected); + HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), content, expected); return CryptographicOperations.FixedTimeEquals(expected, provided); } diff --git a/src/aspnetcore/README.md b/src/aspnetcore/README.md index b3fbc5eca..987044256 100644 --- a/src/aspnetcore/README.md +++ b/src/aspnetcore/README.md @@ -172,7 +172,7 @@ Package provides a model for webhook deserialization: `WebhookNotification`. ### Webhook signature verification middleware -This middleware verifies the `X-Kontent-ai-Signature` header (and the legacy `X-KC-Signature` header). Returns `401 Unauthorized` when the signature is missing or invalid. +This middleware verifies the `X-Kontent-ai-Signature` header, falling back to the legacy `X-KC-Signature` header when the modern one is absent. A request carrying both is verified against the modern one. Returns `401 Unauthorized` when the signature is missing or invalid. `appsettings.json`: diff --git a/src/common/FatalExceptions.cs b/src/common/FatalExceptions.cs index 755d82efc..704052c14 100644 --- a/src/common/FatalExceptions.cs +++ b/src/common/FatalExceptions.cs @@ -7,16 +7,18 @@ namespace Kontent.Ai.Common; /// is no longer sound, so continuing would report a misleading error for a much larger problem. /// /// +/// is deliberately absent: it cannot be caught on .NET, so listing +/// it suggests a filter that never runs. The AppDomain-related failures are absent for the same reason - +/// this target has no unloadable AppDomains for them to come from. +/// /// Not used by the Management SDK, whose error mapping catches JsonException specifically. +/// /// internal static class FatalExceptions { internal static bool IsFatal(Exception exception) => exception is OutOfMemoryException - or StackOverflowException or AccessViolationException - or AppDomainUnloadedException or BadImageFormatException - or CannotUnloadAppDomainException or InvalidProgramException; } diff --git a/src/common/Http/HttpRetryPredicates.cs b/src/common/Http/HttpRetryPredicates.cs index 07539346e..3ce6a3c75 100644 --- a/src/common/Http/HttpRetryPredicates.cs +++ b/src/common/Http/HttpRetryPredicates.cs @@ -1,6 +1,7 @@ // Shared source, compiled into each SDK assembly - see src/common/README.md. using System.Net; +using Polly.Timeout; namespace Kontent.Ai.Common.Http; @@ -26,6 +27,10 @@ internal static bool IsTransientException(Exception? exception, CancellationToke // TaskCanceledException or wrapping a TimeoutException) is. OperationCanceledException when requestCancellationToken.IsCancellationRequested => false, OperationCanceledException => exception is TaskCanceledException || exception.InnerException is TimeoutException, + // The pipeline's own per-attempt timeout. Retry sits outside timeout, so this is what a hung + // attempt looks like to ShouldHandle - and retrying it on a fresh connection is the entire + // reason that timeout is there. Treating it as terminal would fail the call after one attempt. + TimeoutRejectedException => true, HttpRequestException or TimeoutException => true, _ => false, }; diff --git a/src/common/Http/SdkTrackingHeaders.cs b/src/common/Http/SdkTrackingHeaders.cs index d13743fdb..610637d82 100644 --- a/src/common/Http/SdkTrackingHeaders.cs +++ b/src/common/Http/SdkTrackingHeaders.cs @@ -87,11 +87,19 @@ internal static string GetProductVersion(this Assembly assembly) /// [MethodImpl(MethodImplOptions.NoInlining)] internal static Assembly? FindOriginatingAssembly(Assembly sdkAssembly) - => new StackTrace().GetFrames() + { + // Compared by simple name, not by full name: a full name carries the version, and nothing pins + // AssemblyVersion, so the reference an integration recorded when it was built stops matching the + // SDK it is running against on the first release after that. Attribution would go silently + // missing for every consumer who had not rebuilt. + var sdkName = sdkAssembly.GetName().Name; + + return new StackTrace().GetFrames() .Select(frame => frame.GetMethod()?.ReflectedType?.Assembly) .Distinct() .OfType() .LastOrDefault(assembly => assembly .GetReferencedAssemblies() - .Any(referenced => referenced.FullName == sdkAssembly.FullName)); + .Any(referenced => string.Equals(referenced.Name, sdkName, StringComparison.OrdinalIgnoreCase))); + } } diff --git a/src/common/NamedClients.cs b/src/common/NamedClients.cs index 79785cda2..aa5f52ea0 100644 --- a/src/common/NamedClients.cs +++ b/src/common/NamedClients.cs @@ -22,10 +22,10 @@ internal static void ValidateName(string name) { ArgumentException.ThrowIfNullOrWhiteSpace(name); - if (name.Trim() != name || name.Contains(' ')) + if (name.Any(char.IsWhiteSpace)) { throw new ArgumentException( - "Client name cannot contain leading/trailing whitespace, or contain spaces. Use underscores or hyphens instead.", + "Client name cannot contain whitespace. Use underscores or hyphens instead.", nameof(name)); } } diff --git a/src/delivery/CHANGELOG.md b/src/delivery/CHANGELOG.md index b67474b0a..116e6cdf1 100644 --- a/src/delivery/CHANGELOG.md +++ b/src/delivery/CHANGELOG.md @@ -8,6 +8,63 @@ Entries before the move to this monorepo were imported from the GitHub Releases ## Unreleased +## 20.0.0-rc.2 (2026-08-12) _(prerelease)_ + +### Breaking changes + +- **The caching package's registration class is renamed to `DeliveryCacheServiceCollectionExtensions`.** It and the Delivery SDK both declared `Kontent.Ai.Delivery.ServiceCollectionExtensions`, so two packages owned one full type name — and since `Kontent.Ai.Delivery.Caching` depends on `Kontent.Ai.Delivery`, every consumer has both and could name neither: referring to it was `CS0433`, with no way to disambiguate. Nothing that compiled before stops compiling. The namespace is unchanged, so `using Kontent.Ai.Delivery;` and every `services.AddDeliveryMemoryCache(...)` / `AddDeliveryHybridCache(...)` / `AddDeliveryCacheManager(...)` call is exactly as it was; only code that named the type explicitly is affected, and that could not have built. + +- **The source generator emits its marker attribute as `internal`.** `ContentTypeCodenameAttribute` is generated into each referencing compilation, so a `public` one put the same type name into every assembly that uses the generator. Two such projects referencing each other stopped compiling with `CS0436`/`CS0433`, and the only fix available to the consumer was to drop a project reference. Emitting it `internal` — standard practice for generated marker attributes — gives each assembly its own copy. Code that only applies the attribute to its own models is unaffected; code that exposed it across an assembly boundary was in the broken configuration already. + +### Added + +- **`ConfigureFusionCache`** on `DeliveryCacheOptions`, from `Kontent.Ai.Delivery.Caching`, configures the underlying cache with `FusionCacheOptions` typed: + + ```csharp + services.AddDeliveryMemoryCache(opts => opts + .ConfigureFusionCache(fusion => fusion.DefaultEntryOptions.EagerRefreshThreshold = 0.8f)); + ``` + + The `ConfigureFusionCacheOptions` property it sets stays as it was, `Action?`, because it is declared in `Kontent.Ai.Delivery.Abstractions` and that package deliberately references nothing. The extension lives where FusionCache is already referenced, so the cast happens once here instead of in every caller. + +### Fixed + +- **`DeliveryClientBuilder.Build()` documents the exception it actually throws.** It promised `InvalidOperationException` for invalid configuration; the validation runs in the options pipeline, so what surfaces is `OptionsValidationException`. Now pinned by a test, and the inline note about *why* it fires during the build is corrected too. + +- **The caching package no longer re-registers the dependency extractor the SDK already registers**, and the interface no longer describes a no-op implementation that does not exist — there is one implementation. + +- **`IDeliveryClient` says which queries are cached.** Languages, single content elements and used-in queries always reach the API; that was a decision nowhere written down. + +- **`DeliverySourceTrackingHeaderAttribute` is sealed**, and both `WithEnvironmentId` overloads describe setting the environment rather than constructing the builder. + +- **A rich-text document is disposed once parsed.** The AngleSharp document was left to finalization on every rich-text element mapped; the parsed blocks hold plain strings and lists rather than document nodes, so nothing needed it to stay alive. + +- **A client name containing a tab or newline is rejected like one containing a space.** The rule trimmed and then looked for spaces, so other whitespace passed validation and left a name that is invisible at the point of failure. The caching package also carried its own copy of the rule, which is now the shared one. + +- **Dynamic queries carry their dependency keys, on every page.** `GetItem`/`GetItems` without a typed model returned results whose `DependencyKeys` were `null`, while the typed queries forwarded them — so output-cache tagging, which those keys exist for, had nothing to tag with on the dynamic path. Paging through a dynamic listing dropped them the same way from the second page on. + +- **`ImageUrlBuilder` keeps a query the asset URL already carries.** Transformations were applied as a relative reference with its own query, which replaces the base URL's query outright. An asset URL produced by a default rendition preset therefore lost its rendition the moment any transformation was added. The two are merged now, with an explicit transformation winning where both set the same key. + +- **A cache miss in raw-JSON mode hydrates once instead of twice.** The factory already builds the value to collect its dependency keys, and the payload it stored was then parsed and mapped a second time to answer the same call. The call that produced the value now reuses it; a cache hit or a background refresh still rehydrates, as it must. + +- **The source generator no longer pins compilations in the IDE's incremental cache.** Its pipeline model carried a `Location`, which holds its `SourceTree` alive — and pipeline values are retained for as long as the generator is loaded, so every edit accumulated another rooted syntax tree and the compilation behind it. The position is stored as a path and spans, and the `Location` is rebuilt only when a diagnostic is reported. + +- **Options handed to the SDK prebuilt are copied by reflection rather than property by property.** `DeliveryOptions.CopyTo` listed the properties it carried, which keeps compiling when an option is added and silently stops carrying it — a value the caller set that the client never sees. It now uses the same copier the other SDKs do. + +- **The `X-KC-SOURCE` header keeps naming the integration that made the call.** Attribution matched the SDK assembly by full name, which carries the version — and nothing pins `AssemblyVersion`, so the reference an integration recorded when it was built stopped matching on the first SDK release after that. The header then went silently missing for every consumer who had not rebuilt. Matching is now by simple name. + +- **Rich-text tag resolvers keep their place in registration order, and the description is no longer dispatch.** `WithHtmlNodeResolver(tagName, ...)` registrations were lifted into a lookup consulted before any predicate resolver, so a tag resolver won however late it was registered — against the documented "evaluated in registration order, first match wins". Membership of that lookup was decided by whether the resolver's *description* started with `Tag=`, so a predicate resolver a caller happened to describe that way was silently promoted into it. Registering the same tag twice threw an `ArgumentException` from `Build()`, where every other registration is resolved by order. All three now follow the one documented rule: one ordered pass, first match wins, tag registrations included. The public builder API is unchanged. + +- **Cache keys are scoped to the environment they were fetched from.** A key was built from the query alone, so "the item `article`" produced the same key in every environment. Two applications sharing one distributed cache and pointing at different environments served each other's content, silently and in both directions. The environment id is now part of the key prefix, ahead of which an explicit `KeyPrefix` still separates clients within one environment. Existing distributed cache entries are not readable under the new keys and are simply missed once, then rewritten — nothing to migrate, but expect one cold start after upgrading. + +- **An application's own `JsonSerializerOptions` registration is no longer taken over as the SDK's wire serializer.** `AddDeliveryClient` looked for a singleton registered under `JsonSerializerOptions` and, finding one, used it to read every API response. Registering that type is an ordinary thing for an application to do, and the options it registers do not carry `ContentItemConverterFactory` - without which no raw item JSON is captured, hydration has nothing to map from, and typed models come back empty. Nothing threw and nothing was logged. The SDK now keeps its serializer under a type only it names, so an application's registration stays the application's, and a registration made through a factory or under a service key no longer splits Refit and the mappers onto different serializers. + +- **A distributed cache no longer strips taxonomy and multiple-choice data out of content types.** The distributed tier's serializer was built without the SDK's own converters, so it wrote content type elements by their declared type: `TaxonomyElement.TaxonomyGroup` and `MultipleChoiceElement.Options` went in and never came out. A node reading such an entry back got a plain `ContentElement` - an `InvalidCastException` for anything casting to `ITaxonomyElement` or `IMultipleChoiceElement`, and missing data for anything that did not. The writing node was unaffected because it answers from its own memory tier, so this surfaced only on a second instance, which is the case a distributed cache exists for. `ContentElementConverter` now writes an element by its runtime type - the wire's own `type` field is the discriminator on the way back - and the distributed tier uses the SDK's serializer unless one is supplied. + +- **A request is bounded again when the SDK's own resilience pipeline is not the one installed.** `HttpClient.Timeout` was set to `Timeout.InfiniteTimeSpan` unconditionally, on the premise that the resilience pipeline owns timing - but the 30-second per-attempt timeout that premise rests on exists only while `EnableResilience` is left on and no `configureResilience` hook replaces the default pipeline. Setting `EnableResilience = false`, or supplying a pipeline that adds no timeout of its own, therefore left a call with no attempt timeout, no overall timeout and no ceiling of any kind, so a connection that stopped responding hung the caller indefinitely. The ceiling is now lifted only for the default pipeline; otherwise `HttpClient`'s 100-second default applies, as it did before this SDK moved to a resilience pipeline. A custom pipeline that legitimately needs longer can raise it through `configureHttpClient`. + +- **An attempt the resilience pipeline timed out is now retried instead of failing the whole call.** The default pipeline wraps retry around a 30-second per-attempt timeout, so a hung attempt reaches the retry as Polly's `TimeoutRejectedException` - a type the SDK's transient classifier did not recognise. The single situation that per-attempt timeout exists for, a connection that stops responding and that a fresh attempt would recover from, therefore failed the whole call after 30 seconds with no retries at all. + ## 20.0.0-rc.1 (2026-08-07) _(prerelease)_ Targets .NET 10. Every package in this product moves from `net8.0` to `net10.0`, which is why this is a major release, and Refit's transport is upgraded across four major versions. Beyond the target framework the public API is almost untouched — one configuration hook is removed, and two request-building details change in ways that are visible in logs but not in results. @@ -61,7 +118,7 @@ Targets .NET 10. Every package in this product moves from `net8.0` to `net10.0`, services.AddDeliveryHybridCache(); ``` - Nothing changes for a single-instance application, and nothing changes if no backplane is registered — the in-memory tier stays bypassed there, as before. With a backplane it is used as well, since it is then kept in step across nodes. + Nothing changes for a single-instance application, which needs no backplane. FusionCache keeps an in-memory tier in front of the distributed one and uses it either way; the backplane is what keeps those tiers in step across nodes, so without one an invalidation still reaches only the node that performed it. - **Cache invalidation no longer skips items whose codename looks like a component's.** Components were told apart by the shape of their generated codename — a `_`-separated group of four characters starting `01`, as in `n373888cc_34e2_01e1_1820_3cb52ab1b2a1`. Authored codenames collide with that: `Product SKU 0123 Blue` becomes `product_sku_0123_blue`, whose third group is `0123`. Such an item was silently given no `item_` dependency key, so a webhook naming it evicted nothing and the cached response kept being served until it expired — the failure was invisible and depended on how content was named. Components are now recognised from the response instead: the Delivery API gives every content item a `workflow` and `workflow_step` and gives components neither. Where that signal is not available the item is tracked regardless, because the two mistakes are not equal — a dependency key for a component is one entry nobody ever looks up, while a missing key for an item is stale content. diff --git a/src/delivery/Kontent.Ai.Delivery.Abstractions.Tests/ApiApproval/PublicApiApprovalTests.PublicApi_ShouldNotChangeUnexpectedly.verified.txt b/src/delivery/Kontent.Ai.Delivery.Abstractions.Tests/ApiApproval/PublicApiApprovalTests.PublicApi_ShouldNotChangeUnexpectedly.verified.txt index 893f52e25..5f47ac993 100644 --- a/src/delivery/Kontent.Ai.Delivery.Abstractions.Tests/ApiApproval/PublicApiApprovalTests.PublicApi_ShouldNotChangeUnexpectedly.verified.txt +++ b/src/delivery/Kontent.Ai.Delivery.Abstractions.Tests/ApiApproval/PublicApiApprovalTests.PublicApi_ShouldNotChangeUnexpectedly.verified.txt @@ -14,7 +14,7 @@ public sealed class CacheEntry`1 : IEquatable> CacheEntry $() Int32 GetHashCode() String ToString() - Void Deconstruct(T& Value, IEnumerable`1& Dependencies) + Void Deconstruct(out T Value, out IEnumerable Dependencies) // Kontent.Ai.Delivery.Abstractions public sealed class CacheResult`1 : IEquatable> @@ -27,15 +27,15 @@ public sealed class CacheResult`1 : IEquatable> CacheResult $() Int32 GetHashCode() String ToString() - Void Deconstruct(T& Value, IReadOnlyList`1& DependencyKeys) + Void Deconstruct(out T Value, out IReadOnlyList DependencyKeys) // Kontent.Ai.Delivery.Abstractions public enum CacheStorageMode - HydratedObject - RawJson + HydratedObject = 0 + RawJson = 1 // Kontent.Ai.Delivery.Abstractions -public sealed class DeliveryCacheDependencies +public static class DeliveryCacheDependencies const String ItemsListScope = scope_items_list const String TaxonomiesListScope = scope_taxonomies_list const String TypesListScope = scope_types_list @@ -69,7 +69,7 @@ public sealed class DeliveryOptions : IValidatableObject IEnumerable Validate(ValidationContext validationContext) // Kontent.Ai.Delivery.Abstractions -public sealed class DeliveryOptionsExtensions +public static class DeliveryOptionsExtensions static String GetBaseUrl(DeliveryOptions options) static String? GetApiKey(DeliveryOptions options) @@ -614,13 +614,13 @@ public interface IUsedInItemSystemAttributes : ISystemAttributes, ISystemBaseAtt // Kontent.Ai.Delivery.Abstractions public enum LanguageFallbackMode - Disabled - Enabled + Disabled = 1 + Enabled = 0 // Kontent.Ai.Delivery.Abstractions public enum OrderingMode - Ascending - Descending + Ascending = 0 + Descending = 1 // Kontent.Ai.Delivery.Abstractions public sealed class RequiredIfAttribute : ValidationAttribute @@ -629,8 +629,8 @@ public sealed class RequiredIfAttribute : ValidationAttribute // Kontent.Ai.Delivery.Abstractions public enum ResponseSource - Cache - Cdn - FailSafe - Origin + Cache = 2 + Cdn = 1 + FailSafe = 3 + Origin = 0 diff --git a/src/delivery/Kontent.Ai.Delivery.Abstractions.Tests/CheckNamespaces.cs b/src/delivery/Kontent.Ai.Delivery.Abstractions.Tests/CheckNamespaces.cs index 0a313bbd3..214b24449 100644 --- a/src/delivery/Kontent.Ai.Delivery.Abstractions.Tests/CheckNamespaces.cs +++ b/src/delivery/Kontent.Ai.Delivery.Abstractions.Tests/CheckNamespaces.cs @@ -26,7 +26,11 @@ public void AllNamespacesAreCorrect() // Coverage instrumentation weaves a tracker type into the assembly when the build // collects coverage. Like the compiler-generated types above it is a build artifact, // not part of the shipped surface. - .Where(t => !t.Namespace!.StartsWith("Coverlet.", StringComparison.Ordinal)); + .Where(t => !t.Namespace!.StartsWith("Coverlet.", StringComparison.Ordinal)) + // Shared source from src/common is compiled in as internal types (see src/common/README.md). + // Like the instrumentation above it is a build-time inclusion rather than part of the contract + // this rule protects - a consumer never sees it, whatever namespace it declares. + .Where(t => !t.Namespace!.StartsWith("Kontent.Ai.Common", StringComparison.Ordinal)); Assert.All( typesToCheck, diff --git a/src/delivery/Kontent.Ai.Delivery.Abstractions/Caching/DeliveryCacheOptions.cs b/src/delivery/Kontent.Ai.Delivery.Abstractions/Caching/DeliveryCacheOptions.cs index 85dd0e8cd..3df9f11ac 100644 --- a/src/delivery/Kontent.Ai.Delivery.Abstractions/Caching/DeliveryCacheOptions.cs +++ b/src/delivery/Kontent.Ai.Delivery.Abstractions/Caching/DeliveryCacheOptions.cs @@ -96,7 +96,9 @@ public sealed class DeliveryCacheOptions /// override or extend any setting. /// /// - /// Example usage: + /// Prefer the ConfigureFusionCache extension from Kontent.Ai.Delivery.Caching, which takes + /// the options typed; this property is only because this package references no + /// FusionCache types. Assigning it directly means casting: /// /// cacheOptions.ConfigureFusionCacheOptions = options => /// { diff --git a/src/delivery/Kontent.Ai.Delivery.Abstractions/Configuration/DeliveryOptions.cs b/src/delivery/Kontent.Ai.Delivery.Abstractions/Configuration/DeliveryOptions.cs index 0feb2e187..580ab9071 100644 --- a/src/delivery/Kontent.Ai.Delivery.Abstractions/Configuration/DeliveryOptions.cs +++ b/src/delivery/Kontent.Ai.Delivery.Abstractions/Configuration/DeliveryOptions.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Kontent.Ai.Common; namespace Kontent.Ai.Delivery.Abstractions; @@ -89,16 +90,10 @@ internal void CopyTo(DeliveryOptions destination) { ArgumentNullException.ThrowIfNull(destination); - destination.EnvironmentId = EnvironmentId; - destination.EnableResilience = EnableResilience; - destination.ProductionEndpoint = ProductionEndpoint; - destination.PreviewEndpoint = PreviewEndpoint; - destination.PreviewApiKey = PreviewApiKey; - destination.UsePreviewApi = UsePreviewApi; - destination.UseSecureAccess = UseSecureAccess; - destination.SecureAccessApiKey = SecureAccessApiKey; - destination.DefaultRenditionPreset = DefaultRenditionPreset; - destination.CustomAssetDomain = CustomAssetDomain; + // Reflected rather than assigned property by property: a hand-written list keeps compiling when a + // new option is added and silently stops carrying it, which is a value the caller set and the + // client never sees. + OptionsCopier.Copy(this, destination); } /// diff --git a/src/delivery/Kontent.Ai.Delivery.Abstractions/ContentItems/DateTime/IDateTimeContent.cs b/src/delivery/Kontent.Ai.Delivery.Abstractions/ContentItems/DateTime/IDateTimeContent.cs index 451798839..fb7452342 100644 --- a/src/delivery/Kontent.Ai.Delivery.Abstractions/ContentItems/DateTime/IDateTimeContent.cs +++ b/src/delivery/Kontent.Ai.Delivery.Abstractions/ContentItems/DateTime/IDateTimeContent.cs @@ -6,12 +6,14 @@ namespace Kontent.Ai.Delivery.Abstractions; public interface IDateTimeContent { /// - /// Gets the value of DateTime element + /// The instant the element holds, as the UTC value the API stores. Null when the element is empty. /// DateTime? Value { get; } /// - /// Gets the Timezone of DateTime element + /// IANA zone name the UI displays in (e.g. Europe/Prague); null when unset. + /// It never shifts the instant — pass it to to + /// render local wall time. /// string? DisplayTimezone { get; } } diff --git a/src/delivery/Kontent.Ai.Delivery.Abstractions/ContentItems/IContentDependencyExtractor.cs b/src/delivery/Kontent.Ai.Delivery.Abstractions/ContentItems/IContentDependencyExtractor.cs index 549d5de9c..4444d8692 100644 --- a/src/delivery/Kontent.Ai.Delivery.Abstractions/ContentItems/IContentDependencyExtractor.cs +++ b/src/delivery/Kontent.Ai.Delivery.Abstractions/ContentItems/IContentDependencyExtractor.cs @@ -11,9 +11,6 @@ namespace Kontent.Ai.Delivery.Abstractions; /// Implementations analyze element values to identify dependencies on assets, taxonomies, /// and linked items, which are tracked in a for cache invalidation. /// -/// -/// A no-op implementation is used when caching is disabled. -/// /// internal interface IContentDependencyExtractor { diff --git a/src/delivery/Kontent.Ai.Delivery.Abstractions/ContentItems/RichText/Resolution/IHtmlResolverBuilder.cs b/src/delivery/Kontent.Ai.Delivery.Abstractions/ContentItems/RichText/Resolution/IHtmlResolverBuilder.cs index b1ff75bdc..e5f7566a1 100644 --- a/src/delivery/Kontent.Ai.Delivery.Abstractions/ContentItems/RichText/Resolution/IHtmlResolverBuilder.cs +++ b/src/delivery/Kontent.Ai.Delivery.Abstractions/ContentItems/RichText/Resolution/IHtmlResolverBuilder.cs @@ -161,7 +161,7 @@ IHtmlResolverBuilder WithContentResolvers( /// /// Predicate to determine if this resolver applies to a node. /// The resolver function for matching nodes. - /// Optional description for debugging purposes. + /// Optional label, used for debugging only - it does not affect matching. /// This builder for method chaining. IHtmlResolverBuilder WithHtmlNodeResolver( HtmlNodePredicate predicate, @@ -172,6 +172,11 @@ IHtmlResolverBuilder WithHtmlNodeResolver( /// Convenience method to register a resolver for HTML nodes with a specific tag name. /// Tag name matching is case-insensitive. /// + /// + /// A tag registration takes its place in the same order as every other conditional resolver, so an + /// earlier predicate that also matches the node wins, and registering the same tag twice leaves the + /// first one in effect. + /// /// The HTML tag name to match (e.g., "h1", "p", "div"). /// The resolver function for matching nodes. /// This builder for method chaining. diff --git a/src/delivery/Kontent.Ai.Delivery.Abstractions/IDeliveryClient.cs b/src/delivery/Kontent.Ai.Delivery.Abstractions/IDeliveryClient.cs index 5362e01b0..bb462ee6c 100644 --- a/src/delivery/Kontent.Ai.Delivery.Abstractions/IDeliveryClient.cs +++ b/src/delivery/Kontent.Ai.Delivery.Abstractions/IDeliveryClient.cs @@ -5,9 +5,17 @@ namespace Kontent.Ai.Delivery.Abstractions; /// All methods return query builders that must be executed with ExecuteAsync() to retrieve the actual API response. /// /// +/// +/// Not every query is cached. Items, types and taxonomies go through the registered +/// IDeliveryCacheManager; languages, single content elements and used-in queries always reach the +/// API. Those three are small, rarely hot, and their results are already implied by content that is +/// cached - so they are deliberately left out rather than overlooked. +/// +/// /// This contract carries no disposal. A client resolved from a container is owned by the container, /// which releases it; a client from DeliveryClientBuilder owns the services it was built from /// and is returned as the concrete DeliveryClient, which is disposable. +/// /// public interface IDeliveryClient { diff --git a/src/delivery/Kontent.Ai.Delivery.Abstractions/Kontent.Ai.Delivery.Abstractions.csproj b/src/delivery/Kontent.Ai.Delivery.Abstractions/Kontent.Ai.Delivery.Abstractions.csproj index ab49f628c..3e016a92e 100644 --- a/src/delivery/Kontent.Ai.Delivery.Abstractions/Kontent.Ai.Delivery.Abstractions.csproj +++ b/src/delivery/Kontent.Ai.Delivery.Abstractions/Kontent.Ai.Delivery.Abstractions.csproj @@ -10,6 +10,10 @@ + + + + <_Parameter1>Kontent.Ai.Delivery.Tests diff --git a/src/delivery/Kontent.Ai.Delivery.Caching/Extensions/DeliveryCacheOptionsExtensions.cs b/src/delivery/Kontent.Ai.Delivery.Caching/Extensions/DeliveryCacheOptionsExtensions.cs new file mode 100644 index 000000000..8ba218335 --- /dev/null +++ b/src/delivery/Kontent.Ai.Delivery.Caching/Extensions/DeliveryCacheOptionsExtensions.cs @@ -0,0 +1,39 @@ +using ZiggyCreatures.Caching.Fusion; + +namespace Kontent.Ai.Delivery; + +/// +/// Caching-package extensions for . +/// +public static class DeliveryCacheOptionsExtensions +{ + /// + /// Configures the underlying FusionCache instance, with the options typed. + /// + /// + /// is typed as + /// because it lives in Kontent.Ai.Delivery.Abstractions, which references no packages at all + /// and should not start referencing FusionCache to expose one escape hatch. This package already + /// references it, so the cast belongs here rather than in every caller. + /// + /// The cache options to configure. + /// Receives the after the SDK's defaults are applied. + /// The same instance, for chaining. + /// + /// + /// services.AddDeliveryHybridCache(options => options + /// .ConfigureFusionCache(fusion => fusion.DefaultEntryOptions.AllowBackgroundBackplaneOperations = true)); + /// + /// + public static DeliveryCacheOptions ConfigureFusionCache( + this DeliveryCacheOptions options, + Action configure) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(configure); + + options.ConfigureFusionCacheOptions = fusionOptions => configure((FusionCacheOptions)fusionOptions); + + return options; + } +} diff --git a/src/delivery/Kontent.Ai.Delivery.Caching/Extensions/ServiceCollectionExtensions.cs b/src/delivery/Kontent.Ai.Delivery.Caching/Extensions/DeliveryCacheServiceCollectionExtensions.cs similarity index 95% rename from src/delivery/Kontent.Ai.Delivery.Caching/Extensions/ServiceCollectionExtensions.cs rename to src/delivery/Kontent.Ai.Delivery.Caching/Extensions/DeliveryCacheServiceCollectionExtensions.cs index 3dbf9aba3..1ffd84688 100644 --- a/src/delivery/Kontent.Ai.Delivery.Caching/Extensions/ServiceCollectionExtensions.cs +++ b/src/delivery/Kontent.Ai.Delivery.Caching/Extensions/DeliveryCacheServiceCollectionExtensions.cs @@ -7,13 +7,20 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Kontent.Ai.Delivery; /// /// Extension methods for registering Kontent.ai Delivery SDK caching services. /// -public static class ServiceCollectionExtensions +/// +/// Named for the package rather than the generic ServiceCollectionExtensions: the Delivery SDK +/// declares a class of that name in this same namespace, and two packages cannot both own one full type +/// name - a consumer, who always has both since this package depends on that one, could not name either. +/// The namespace is unchanged, so every using and every call site is unaffected. +/// +public static class DeliveryCacheServiceCollectionExtensions { /// /// Registers a custom cache manager for the default Delivery client. @@ -38,7 +45,7 @@ public static IServiceCollection AddDeliveryCacheManager( Func createCacheManager) { ArgumentNullException.ThrowIfNull(services); - ValidateClientName(clientName); + NamedClients.ValidateName(clientName); ArgumentNullException.ThrowIfNull(createCacheManager); return RegisterCacheManager(services, clientName, createCacheManager); @@ -214,7 +221,7 @@ public static IServiceCollection AddDeliveryMemoryCache( Action configureCacheOptions) { ArgumentNullException.ThrowIfNull(services); - ValidateClientName(clientName); + NamedClients.ValidateName(clientName); ArgumentNullException.ThrowIfNull(configureCacheOptions); var cacheOptions = CreateCacheOptions(clientName, configureCacheOptions); @@ -251,7 +258,7 @@ public static IServiceCollection AddDeliveryMemoryCache( Action configureCacheOptions) { ArgumentNullException.ThrowIfNull(services); - ValidateClientName(clientName); + NamedClients.ValidateName(clientName); ArgumentNullException.ThrowIfNull(configureCacheOptions); return AddDeliveryMemoryCacheCore( @@ -456,7 +463,7 @@ public static IServiceCollection AddDeliveryHybridCache( Action configureCacheOptions) { ArgumentNullException.ThrowIfNull(services); - ValidateClientName(clientName); + NamedClients.ValidateName(clientName); ArgumentNullException.ThrowIfNull(configureCacheOptions); var cacheOptions = CreateCacheOptions(clientName, configureCacheOptions); @@ -495,7 +502,7 @@ public static IServiceCollection AddDeliveryHybridCache( Action configureCacheOptions) { ArgumentNullException.ThrowIfNull(services); - ValidateClientName(clientName); + NamedClients.ValidateName(clientName); ArgumentNullException.ThrowIfNull(configureCacheOptions); return AddDeliveryHybridCacheCore( @@ -518,7 +525,8 @@ private static IServiceCollection AddDeliveryMemoryCacheCore( sp => new MemoryCacheManager( sp.GetRequiredService(), cacheOptionsFactory(sp), - sp.GetService>())); + sp.GetService>(), + EnvironmentIdOf(sp, clientName))); } private static IServiceCollection AddDeliveryHybridCacheCore( @@ -535,7 +543,8 @@ private static IServiceCollection AddDeliveryHybridCacheCore( logger: sp.GetService>(), // Registered by the consumer the usual FusionCache way, e.g. // services.AddFusionCacheStackExchangeRedisBackplane(...). - backplane: sp.GetService())); + backplane: sp.GetService(), + environmentId: EnvironmentIdOf(sp, clientName))); } private static IServiceCollection RegisterCacheManager( @@ -545,7 +554,6 @@ private static IServiceCollection RegisterCacheManager( { RemoveExistingCacheManagerRegistration(services, clientName); services.AddKeyedSingleton(clientName, (sp, _) => createCacheManager(sp)); - services.Replace(ServiceDescriptor.Singleton()); return services; } @@ -562,18 +570,6 @@ private static void RemoveExistingCacheManagerRegistration(IServiceCollection se } } - private static void ValidateClientName(string name) - { - ArgumentException.ThrowIfNullOrWhiteSpace(name); - - if (name.Trim() != name || name.Contains(' ')) - { - throw new ArgumentException( - "Client name cannot contain leading/trailing whitespace, or contain spaces. Use underscores or hyphens instead.", - nameof(name)); - } - } - private static DeliveryCacheOptions CreateCacheOptions( string clientName, Action configureCacheOptions) @@ -593,6 +589,11 @@ private static DeliveryCacheOptions ValidateCacheOptions(DeliveryCacheOptions ca return cacheOptions; } + // The environment the cached content actually came from, so entries cannot be served to a client + // pointing somewhere else - see FusionCacheManager.ComposeKeyPrefix. + private static string EnvironmentIdOf(IServiceProvider sp, string clientName) => + sp.GetRequiredService>().Get(clientName).EnvironmentId; + private static string ResolveCacheKeyPrefix(string clientName, string? keyPrefix) { if (keyPrefix is not null) diff --git a/src/delivery/Kontent.Ai.Delivery.Caching/FusionCacheManager.cs b/src/delivery/Kontent.Ai.Delivery.Caching/FusionCacheManager.cs index 492101674..47f3ea1cf 100644 --- a/src/delivery/Kontent.Ai.Delivery.Caching/FusionCacheManager.cs +++ b/src/delivery/Kontent.Ai.Delivery.Caching/FusionCacheManager.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Text.Json; +using Kontent.Ai.Delivery.Configuration; using Kontent.Ai.Delivery.Logging; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; @@ -68,17 +69,34 @@ private FusionCacheManager( SubscribeFailSafeStateEvents(); } + /// + /// Builds the segment every cache key and dependency tag is prefixed with. + /// + /// + /// The environment id is part of it because a cache store can outlive the process and be shared: two + /// applications pointing at different environments and sharing one Redis would otherwise compute the + /// same key for "the item article" and serve each other's content. KeyPrefix stays in + /// front of it, so an explicit prefix still separates clients within one environment. + /// + private static string ComposeKeyPrefix(string? keyPrefix, string? environmentId) + { + var parts = new[] { keyPrefix, environmentId }.Where(part => !string.IsNullOrEmpty(part)); + + return parts.Any() ? $"{string.Join(':', parts)}:" : string.Empty; + } + public static FusionCacheManager CreateMemory( IMemoryCache memoryCache, DeliveryCacheOptions cacheOptions, - ILogger? logger = null) + ILogger? logger = null, + string? environmentId = null) { ArgumentNullException.ThrowIfNull(memoryCache); ArgumentNullException.ThrowIfNull(cacheOptions); var effectiveExpiration = cacheOptions.DefaultExpiration; var keyPrefix = cacheOptions.KeyPrefix; - var prefixSegment = string.IsNullOrEmpty(keyPrefix) ? "" : $"{keyPrefix}:"; + var prefixSegment = ComposeKeyPrefix(keyPrefix, environmentId); var defaultEntryOptions = new FusionCacheEntryOptions { @@ -143,25 +161,26 @@ public static FusionCacheManager CreateMemory( /// Builds a manager over a distributed cache. /// /// - /// FusionCache always has a memory tier in front of the distributed one. Invalidation state is held - /// per instance, so it reaches other nodes only over a backplane - without one, a second node keeps - /// serving content this node has evicted, whether or not the memory tier is in play. A backplane is - /// therefore what makes multi-node invalidation work; once one is present the memory tier is kept in - /// step and is used, and without one it is bypassed. + /// FusionCache always has a memory tier in front of the distributed one, and it is used either way - + /// there is no distributed-only mode. Invalidation state is held per instance, so it reaches other + /// nodes only over a backplane; without one, a second node keeps serving content this node has + /// evicted until the entry expires. A backplane is therefore what makes multi-node invalidation work, + /// and what keeps the memory tiers in step. /// public static FusionCacheManager CreateHybrid( IDistributedCache distributedCache, DeliveryCacheOptions cacheOptions, JsonSerializerOptions? serializerOptions = null, ILogger? logger = null, - IFusionCacheBackplane? backplane = null) + IFusionCacheBackplane? backplane = null, + string? environmentId = null) { ArgumentNullException.ThrowIfNull(distributedCache); ArgumentNullException.ThrowIfNull(cacheOptions); var effectiveExpiration = cacheOptions.DefaultExpiration; var keyPrefix = cacheOptions.KeyPrefix; - var prefixSegment = string.IsNullOrEmpty(keyPrefix) ? "" : $"{keyPrefix}:"; + var prefixSegment = ComposeKeyPrefix(keyPrefix, environmentId); var defaultEntryOptions = new FusionCacheEntryOptions { @@ -169,10 +188,7 @@ public static FusionCacheManager CreateHybrid( AllowBackgroundBackplaneOperations = false, ReThrowDistributedCacheExceptions = false, ReThrowSerializationExceptions = true, - ReThrowBackplaneExceptions = false, - // The memory tier is safe to use only when a backplane keeps the nodes in step. - SkipMemoryCacheRead = backplane is null, - SkipMemoryCacheWrite = backplane is null + ReThrowBackplaneExceptions = false }; ApplyCachePolicy(defaultEntryOptions, cacheOptions, effectiveExpiration); @@ -192,7 +208,12 @@ public static FusionCacheManager CreateHybrid( memoryCache: null, logger: null); - var serializer = new FusionCacheSystemTextJsonSerializer(serializerOptions); + // Falls back to the SDK's own serializer rather than plain defaults. What this tier stores is wire + // types, and content type elements are polymorphic: without ContentElementConverter the L2 payload + // is written by the declared type, silently dropping TaxonomyElement.TaxonomyGroup and + // MultipleChoiceElement.Options, and every hit comes back as a base ContentElement. + var serializer = new FusionCacheSystemTextJsonSerializer( + serializerOptions ?? RefitSettingsProvider.CreateDefaultJsonSerializerOptions()); fusion.SetupDistributedCache(distributedCache, serializer); if (backplane is not null) diff --git a/src/delivery/Kontent.Ai.Delivery.Caching/HybridCacheManager.cs b/src/delivery/Kontent.Ai.Delivery.Caching/HybridCacheManager.cs index 1644a47c4..fd39799dd 100644 --- a/src/delivery/Kontent.Ai.Delivery.Caching/HybridCacheManager.cs +++ b/src/delivery/Kontent.Ai.Delivery.Caching/HybridCacheManager.cs @@ -9,17 +9,18 @@ namespace Kontent.Ai.Delivery.Caching; /// /// /// FusionCache always has a memory tier in front of the distributed one; there is no distributed-only -/// mode. This manager sets SkipMemoryCacheRead and SkipMemoryCacheWrite on its default -/// entry options to keep that tier out of the way, because no backplane is configured: without one an -/// invalidation reaches only the node that performed it, and a second instance would keep serving content -/// a webhook had already evicted. Coherence is chosen over the latency the memory tier would save. +/// mode, and this manager uses both tiers. The distributed one is what another node reads from, and a +/// backplane is what keeps the memory tiers in step: without one an invalidation reaches only the node +/// that performed it, so a second instance can go on serving content a webhook already evicted until the +/// entry expires. Multi-node deployments therefore need an . /// internal sealed class HybridCacheManager( IDistributedCache cache, DeliveryCacheOptions cacheOptions, JsonSerializerOptions? jsonSerializerOptions = null, ILogger? logger = null, - IFusionCacheBackplane? backplane = null) + IFusionCacheBackplane? backplane = null, + string? environmentId = null) : IDeliveryCacheManager, IDeliveryCachePurger, IFailSafeStateProvider, IDisposable { private readonly FusionCacheManager _inner = FusionCacheManager.CreateHybrid( @@ -27,7 +28,8 @@ internal sealed class HybridCacheManager( cacheOptions, jsonSerializerOptions, logger, - backplane); + backplane, + environmentId); /// public CacheStorageMode StorageMode => _inner.StorageMode; diff --git a/src/delivery/Kontent.Ai.Delivery.Caching/Kontent.Ai.Delivery.Caching.csproj b/src/delivery/Kontent.Ai.Delivery.Caching/Kontent.Ai.Delivery.Caching.csproj index 2fc0662b5..92ecd3ef3 100644 --- a/src/delivery/Kontent.Ai.Delivery.Caching/Kontent.Ai.Delivery.Caching.csproj +++ b/src/delivery/Kontent.Ai.Delivery.Caching/Kontent.Ai.Delivery.Caching.csproj @@ -16,6 +16,14 @@ + + <_Parameter1>Kontent.Ai.Delivery.Tests diff --git a/src/delivery/Kontent.Ai.Delivery.Caching/MemoryCacheManager.cs b/src/delivery/Kontent.Ai.Delivery.Caching/MemoryCacheManager.cs index bb784884a..50ab028ec 100644 --- a/src/delivery/Kontent.Ai.Delivery.Caching/MemoryCacheManager.cs +++ b/src/delivery/Kontent.Ai.Delivery.Caching/MemoryCacheManager.cs @@ -9,13 +9,15 @@ namespace Kontent.Ai.Delivery.Caching; internal sealed class MemoryCacheManager( IMemoryCache memoryCache, DeliveryCacheOptions cacheOptions, - ILogger? logger = null) + ILogger? logger = null, + string? environmentId = null) : IDeliveryCacheManager, IDeliveryCachePurger, IFailSafeStateProvider, IDisposable { private readonly FusionCacheManager _inner = FusionCacheManager.CreateMemory( memoryCache, cacheOptions, - logger); + logger, + environmentId); /// public CacheStorageMode StorageMode => _inner.StorageMode; diff --git a/src/delivery/Kontent.Ai.Delivery.SourceGeneration.Tests/ApiApproval/PublicApiApprovalTests.SourceGenerationPublicApi_ShouldNotChangeUnexpectedly.verified.txt b/src/delivery/Kontent.Ai.Delivery.SourceGeneration.Tests/ApiApproval/PublicApiApprovalTests.SourceGenerationPublicApi_ShouldNotChangeUnexpectedly.verified.txt new file mode 100644 index 000000000..6c913030e --- /dev/null +++ b/src/delivery/Kontent.Ai.Delivery.SourceGeneration.Tests/ApiApproval/PublicApiApprovalTests.SourceGenerationPublicApi_ShouldNotChangeUnexpectedly.verified.txt @@ -0,0 +1,5 @@ +// Kontent.Ai.Delivery.SourceGeneration +public sealed class ContentTypeGenerator : IIncrementalGenerator + .ctor() + Void Initialize(IncrementalGeneratorInitializationContext context) + diff --git a/src/delivery/Kontent.Ai.Delivery.SourceGeneration.Tests/ApiApproval/PublicApiApprovalTests.cs b/src/delivery/Kontent.Ai.Delivery.SourceGeneration.Tests/ApiApproval/PublicApiApprovalTests.cs new file mode 100644 index 000000000..7e6381bc4 --- /dev/null +++ b/src/delivery/Kontent.Ai.Delivery.SourceGeneration.Tests/ApiApproval/PublicApiApprovalTests.cs @@ -0,0 +1,10 @@ +using Kontent.Ai.Testing; + +namespace Kontent.Ai.Delivery.SourceGeneration.Tests.ApiApproval; + +public class PublicApiApprovalTests +{ + [Fact] + public Task SourceGenerationPublicApi_ShouldNotChangeUnexpectedly() + => Verify(PublicApiApproval.Surface(typeof(ContentTypeGenerator).Assembly)); +} diff --git a/src/delivery/Kontent.Ai.Delivery.SourceGeneration.Tests/ContentTypeGeneratorTests.cs b/src/delivery/Kontent.Ai.Delivery.SourceGeneration.Tests/ContentTypeGeneratorTests.cs index 6b7829c7b..24aeea6bd 100644 --- a/src/delivery/Kontent.Ai.Delivery.SourceGeneration.Tests/ContentTypeGeneratorTests.cs +++ b/src/delivery/Kontent.Ai.Delivery.SourceGeneration.Tests/ContentTypeGeneratorTests.cs @@ -2,12 +2,27 @@ using AwesomeAssertions; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; -using Xunit; namespace Kontent.Ai.Delivery.SourceGeneration.Tests; public class ContentTypeGeneratorTests { + private static IReadOnlyList LastRunTrees { get; set; } = []; + + [Fact] + public void Generator_EmitsTheMarkerAttributeAsInternal() + { + // Every referencing assembly gets its own copy of this attribute. A public one puts the same + // type name in each of them, so two such projects referencing each other stop compiling with + // CS0436/CS0433 - and the fix would be for the consumer to drop a project reference. + RunGenerator("namespace TestApp.Models;"); + + var attribute = LastRunTrees.Single(tree => tree.Contains("class ContentTypeCodenameAttribute", StringComparison.Ordinal)); + + attribute.Should().Contain("internal sealed class ContentTypeCodenameAttribute"); + attribute.Should().NotContain("public sealed class ContentTypeCodenameAttribute"); + } + [Fact] public void Generator_WithValidContentTypes_GeneratesRegistry() { @@ -311,6 +326,7 @@ private static (ImmutableArray Diagnostics, string Output) RunGenera driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out var diagnostics); var runResult = driver.GetRunResult(); + LastRunTrees = [.. runResult.GeneratedTrees.Select(tree => tree.GetText().ToString())]; var generatedSource = runResult.GeneratedTrees .Select(tree => tree.GetText().ToString()) .FirstOrDefault(text => text.Contains("class GeneratedTypeProvider", StringComparison.Ordinal)) diff --git a/src/delivery/Kontent.Ai.Delivery.SourceGeneration.Tests/Kontent.Ai.Delivery.SourceGeneration.Tests.csproj b/src/delivery/Kontent.Ai.Delivery.SourceGeneration.Tests/Kontent.Ai.Delivery.SourceGeneration.Tests.csproj index 8437341a1..290c366a0 100644 --- a/src/delivery/Kontent.Ai.Delivery.SourceGeneration.Tests/Kontent.Ai.Delivery.SourceGeneration.Tests.csproj +++ b/src/delivery/Kontent.Ai.Delivery.SourceGeneration.Tests/Kontent.Ai.Delivery.SourceGeneration.Tests.csproj @@ -6,12 +6,17 @@ CS1591 + + + + + all diff --git a/src/delivery/Kontent.Ai.Delivery.SourceGeneration/ContentTypeGenerator.cs b/src/delivery/Kontent.Ai.Delivery.SourceGeneration/ContentTypeGenerator.cs index 2a1988c4e..708da1106 100644 --- a/src/delivery/Kontent.Ai.Delivery.SourceGeneration/ContentTypeGenerator.cs +++ b/src/delivery/Kontent.Ai.Delivery.SourceGeneration/ContentTypeGenerator.cs @@ -21,7 +21,7 @@ public sealed class ContentTypeGenerator : IIncrementalGenerator namespace Kontent.Ai.Delivery.Attributes; [global::System.AttributeUsage(global::System.AttributeTargets.Class | global::System.AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] - public sealed class ContentTypeCodenameAttribute : global::System.Attribute + internal sealed class ContentTypeCodenameAttribute : global::System.Attribute { public ContentTypeCodenameAttribute(string codename) { @@ -34,8 +34,10 @@ public ContentTypeCodenameAttribute(string codename) public void Initialize(IncrementalGeneratorInitializationContext context) { - // Emit the marker attribute into the consuming compilation so users do not - // need a separate Attributes package/reference. + // Emit the marker attribute into the consuming compilation so users do not need a separate + // Attributes package/reference. Internal, per standard generator practice: every referencing + // assembly gets its own copy, and a public one would put the same type name in two of them - + // two such projects referencing each other stop compiling with CS0436/CS0433. context.RegisterPostInitializationOutput(static pic => { pic.AddSource("ContentTypeCodenameAttribute.g.cs", SourceText.From(ContentTypeCodenameAttributeSource, Encoding.UTF8)); @@ -73,7 +75,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) codename = arg.Value as string; } - // Get location for diagnostics + // Taken apart rather than carried whole: see ContentTypeInfo. var location = attributeData.ApplicationSyntaxReference?.GetSyntax(ct).GetLocation() ?? context.TargetNode.GetLocation(); @@ -81,7 +83,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) codename: codename, fullyQualifiedTypeName: typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), typeName: typeSymbol.Name, - location: location, + filePath: location.SourceTree?.FilePath ?? string.Empty, + textSpan: location.SourceSpan, + lineSpan: location.GetLineSpan().Span, isInterface: typeSymbol.TypeKind == TypeKind.Interface, isAbstract: typeSymbol.IsAbstract && typeSymbol.TypeKind == TypeKind.Class); } @@ -218,21 +222,33 @@ private static string EscapeString(string value) /// /// Lightweight data carrier for the incremental pipeline. - /// Implements excluding + /// Implements excluding the position /// so the pipeline can skip regeneration when content types haven't changed. /// + /// + /// The position is held as a path and spans rather than as a . A Location keeps + /// its SourceTree alive, and everything a pipeline value holds stays in the incremental cache + /// for as long as the generator is loaded - so carrying one pins a syntax tree, and with it the + /// compilation it came from, for every edit the IDE makes. The Location is rebuilt only when a + /// diagnostic is actually reported. + /// private readonly struct ContentTypeInfo( string? codename, string fullyQualifiedTypeName, string typeName, - Location location, + string filePath, + TextSpan textSpan, + LinePositionSpan lineSpan, bool isInterface, bool isAbstract) : IEquatable { public string? Codename { get; } = codename; public string FullyQualifiedTypeName { get; } = fullyQualifiedTypeName; public string TypeName { get; } = typeName; - public Location Location { get; } = location; + public string FilePath { get; } = filePath; + public TextSpan TextSpan { get; } = textSpan; + public LinePositionSpan LineSpan { get; } = lineSpan; + public Location Location => Location.Create(FilePath, TextSpan, LineSpan); public bool IsInterface { get; } = isInterface; public bool IsAbstract { get; } = isAbstract; diff --git a/src/delivery/Kontent.Ai.Delivery.SourceGeneration/Kontent.Ai.Delivery.SourceGeneration.csproj b/src/delivery/Kontent.Ai.Delivery.SourceGeneration/Kontent.Ai.Delivery.SourceGeneration.csproj index 597efcd09..a51087f21 100644 --- a/src/delivery/Kontent.Ai.Delivery.SourceGeneration/Kontent.Ai.Delivery.SourceGeneration.csproj +++ b/src/delivery/Kontent.Ai.Delivery.SourceGeneration/Kontent.Ai.Delivery.SourceGeneration.csproj @@ -16,20 +16,10 @@ - + diff --git a/src/delivery/Kontent.Ai.Delivery.Tests/ApiApproval/PublicApiApprovalTests.CachingPublicApi_ShouldNotChangeUnexpectedly.verified.txt b/src/delivery/Kontent.Ai.Delivery.Tests/ApiApproval/PublicApiApprovalTests.CachingPublicApi_ShouldNotChangeUnexpectedly.verified.txt index 6648b4a7b..0f93aafd8 100644 --- a/src/delivery/Kontent.Ai.Delivery.Tests/ApiApproval/PublicApiApprovalTests.CachingPublicApi_ShouldNotChangeUnexpectedly.verified.txt +++ b/src/delivery/Kontent.Ai.Delivery.Tests/ApiApproval/PublicApiApprovalTests.CachingPublicApi_ShouldNotChangeUnexpectedly.verified.txt @@ -1,14 +1,9 @@ // Kontent.Ai.Delivery -public sealed class DeliveryClientBuilderExtensions - static DeliveryClientBuilder WithHybridCache(DeliveryClientBuilder builder, IDistributedCache distributedCache, Action configureCacheOptions) - static DeliveryClientBuilder WithHybridCache(DeliveryClientBuilder builder, IDistributedCache distributedCache, Action configureCacheOptions) - static DeliveryClientBuilder WithHybridCache(DeliveryClientBuilder builder, IDistributedCache distributedCache, TimeSpan? defaultExpiration) - static DeliveryClientBuilder WithMemoryCache(DeliveryClientBuilder builder, Action configureCacheOptions) - static DeliveryClientBuilder WithMemoryCache(DeliveryClientBuilder builder, Action configureCacheOptions) - static DeliveryClientBuilder WithMemoryCache(DeliveryClientBuilder builder, TimeSpan? defaultExpiration) +public static class DeliveryCacheOptionsExtensions + static DeliveryCacheOptions ConfigureFusionCache(DeliveryCacheOptions options, Action configure) // Kontent.Ai.Delivery -public sealed class ServiceCollectionExtensions +public static class DeliveryCacheServiceCollectionExtensions static IServiceCollection AddDeliveryCacheManager(IServiceCollection services, Func createCacheManager) static IServiceCollection AddDeliveryCacheManager(IServiceCollection services, String clientName, Func createCacheManager) static IServiceCollection AddDeliveryHybridCache(IServiceCollection services, Action configureCacheOptions) @@ -24,3 +19,12 @@ public sealed class ServiceCollectionExtensions static IServiceCollection AddDeliveryMemoryCache(IServiceCollection services, String clientName, String? keyPrefix, TimeSpan? defaultExpiration) static IServiceCollection AddDeliveryMemoryCache(IServiceCollection services, TimeSpan? defaultExpiration) +// Kontent.Ai.Delivery +public static class DeliveryClientBuilderExtensions + static DeliveryClientBuilder WithHybridCache(DeliveryClientBuilder builder, IDistributedCache distributedCache, Action configureCacheOptions) + static DeliveryClientBuilder WithHybridCache(DeliveryClientBuilder builder, IDistributedCache distributedCache, Action configureCacheOptions) + static DeliveryClientBuilder WithHybridCache(DeliveryClientBuilder builder, IDistributedCache distributedCache, TimeSpan? defaultExpiration) + static DeliveryClientBuilder WithMemoryCache(DeliveryClientBuilder builder, Action configureCacheOptions) + static DeliveryClientBuilder WithMemoryCache(DeliveryClientBuilder builder, Action configureCacheOptions) + static DeliveryClientBuilder WithMemoryCache(DeliveryClientBuilder builder, TimeSpan? defaultExpiration) + diff --git a/src/delivery/Kontent.Ai.Delivery.Tests/ApiApproval/PublicApiApprovalTests.PublicApi_ShouldNotChangeUnexpectedly.verified.txt b/src/delivery/Kontent.Ai.Delivery.Tests/ApiApproval/PublicApiApprovalTests.PublicApi_ShouldNotChangeUnexpectedly.verified.txt index b403585a9..eb8e053ea 100644 --- a/src/delivery/Kontent.Ai.Delivery.Tests/ApiApproval/PublicApiApprovalTests.PublicApi_ShouldNotChangeUnexpectedly.verified.txt +++ b/src/delivery/Kontent.Ai.Delivery.Tests/ApiApproval/PublicApiApprovalTests.PublicApi_ShouldNotChangeUnexpectedly.verified.txt @@ -25,7 +25,7 @@ public sealed class DeliveryClientFactory : IDeliveryClientFactory IDeliveryClient? TryGet(String name) // Kontent.Ai.Delivery -public class DeliverySourceTrackingHeaderAttribute : Attribute +public sealed class DeliverySourceTrackingHeaderAttribute : Attribute .ctor() .ctor(String packageName) .ctor(String packageName, Int32 majorVersion, Int32 minorVersion, Int32 patchVersion, String? preReleaseLabel) @@ -37,7 +37,7 @@ public class DeliverySourceTrackingHeaderAttribute : Attribute String? PreReleaseLabel { get; } // Kontent.Ai.Delivery -public sealed class QueryCacheExtensions +public static class QueryCacheExtensions static IItemQuery WithCacheExpiration(IItemQuery query, TimeSpan? expiration) static IItemsQuery WithCacheExpiration(IItemsQuery query, TimeSpan? expiration) static ITaxonomiesQuery WithCacheExpiration(ITaxonomiesQuery query, TimeSpan? expiration) @@ -46,14 +46,14 @@ public sealed class QueryCacheExtensions static ITypesQuery WithCacheExpiration(ITypesQuery query, TimeSpan? expiration) // Kontent.Ai.Delivery -public sealed class QueryEnumerationExtensions +public static class QueryEnumerationExtensions static IAsyncEnumerable>> EnumerateItemsWithStatusAsync(IEnumerateItemsQuery query, CancellationToken cancellationToken) static IAsyncEnumerable> EnumerateItemsWithStatusAsync(IDynamicEnumerateItemsQuery query, CancellationToken cancellationToken) static IAsyncEnumerable>> EnumerateItemsWithStatusAsync(IAssetUsedInQuery query, CancellationToken cancellationToken) static IAsyncEnumerable>> EnumerateItemsWithStatusAsync(IItemUsedInQuery query, CancellationToken cancellationToken) // Kontent.Ai.Delivery -public sealed class RichTextExtensions +public static class RichTextExtensions static IEnumerable GetContentItemLinks(IRichTextContent richText) static IEnumerable> GetEmbeddedContent(IRichTextContent richText) static IEnumerable> GetEmbeddedContentOfType(IEnumerable blocks) @@ -65,7 +65,7 @@ public sealed class RichTextExtensions static ValueTask ToHtmlAsync(IRichTextContent richText, IHtmlResolver? resolver, CancellationToken cancellationToken) // Kontent.Ai.Delivery -public sealed class ServiceCollectionExtensions +public static class ServiceCollectionExtensions static IServiceCollection AddDeliveryClient(IServiceCollection services, Action configureOptions) static IServiceCollection AddDeliveryClient(IServiceCollection services, Action configureOptions, Action? configureHttpClient, Action>? configureResilience) static IServiceCollection AddDeliveryClient(IServiceCollection services, Action configureOptions) @@ -146,7 +146,7 @@ public sealed class RichTextContent : IEnumerable, IEnumerable, IEnumerator GetEnumerator() // Kontent.Ai.Delivery.ContentItems.RichText.Resolution -public sealed class DefaultResolvers +public static class DefaultResolvers static BlockResolver UrlPatternResolver(IReadOnlyDictionary typePatterns, String? fallbackPattern) static BlockResolver HtmlElementResolver() diff --git a/src/delivery/Kontent.Ai.Delivery.Tests/Builders/Configuration/DeliveryClientBuilderTests.cs b/src/delivery/Kontent.Ai.Delivery.Tests/Builders/Configuration/DeliveryClientBuilderTests.cs index 3b7d598f5..3ba66a7b2 100644 --- a/src/delivery/Kontent.Ai.Delivery.Tests/Builders/Configuration/DeliveryClientBuilderTests.cs +++ b/src/delivery/Kontent.Ai.Delivery.Tests/Builders/Configuration/DeliveryClientBuilderTests.cs @@ -633,4 +633,17 @@ public Task RemoveAsync(string key, CancellationToken token = default) return Task.CompletedTask; } } + + // The type the caller has to catch, which the Build() docs used to state incorrectly. It comes out of + // the options pipeline, not from the builder, so it is not an InvalidOperationException. + [Fact] + public void Build_InvalidOptions_ThrowsOptionsValidationException() + { + var act = () => DeliveryClientBuilder + .WithOptions(o => o.WithEnvironmentId("not-a-guid").Build()) + .Build(); + + var exception = Assert.Throws(act); + Assert.Contains("EnvironmentId", exception.Message); + } } diff --git a/src/delivery/Kontent.Ai.Delivery.Tests/Builders/Configuration/DeliveryOptionsCopyTests.cs b/src/delivery/Kontent.Ai.Delivery.Tests/Builders/Configuration/DeliveryOptionsCopyTests.cs new file mode 100644 index 000000000..6eaafb099 --- /dev/null +++ b/src/delivery/Kontent.Ai.Delivery.Tests/Builders/Configuration/DeliveryOptionsCopyTests.cs @@ -0,0 +1,72 @@ +using System.Reflection; +using Kontent.Ai.Delivery.Abstractions; +using Kontent.Ai.Delivery.Configuration; + +namespace Kontent.Ai.Delivery.Tests.Builders.Configuration; + +/// +/// The copy carries a prebuilt options instance into the DI options pattern, and the build produces one. +/// Naming the properties in either would keep compiling when an option is added and silently stop carrying +/// it — the caller sets a value and the client never sees it. The copy is reflected here for the same reason +/// it is reflected in the source: naming the properties would leave a new one uncovered in exactly the case +/// that matters. +/// +public class DeliveryOptionsCopyTests +{ + [Fact] + public void CopyTo_CarriesEveryWritableProperty() + { + var source = new DeliveryOptions(); + var writable = typeof(DeliveryOptions) + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(p => p is { CanRead: true, CanWrite: true }) + .ToList(); + + Assert.NotEmpty(writable); + foreach (var property in writable) + { + property.SetValue(source, DistinctValueFor(property)); + } + + var destination = new DeliveryOptions(); + source.CopyTo(destination); + + foreach (var property in writable) + { + Assert.Equal(property.GetValue(source), property.GetValue(destination)); + } + } + + [Fact] + public void Build_CarriesWhatTheBuilderWasTold() + { + var built = DeliveryOptionsBuilder.CreateInstance() + .WithEnvironmentId("11111111-1111-1111-1111-111111111111") + .UsePreviewApi("preview-key") + .WithCustomEndpoint("https://preview.example.com/{0}") + .WithDefaultRenditionPreset("mobile") + .WithCustomAssetDomain("assets.example.com") + .DisableRetryPolicy() + .Build(); + + Assert.Equal("11111111-1111-1111-1111-111111111111", built.EnvironmentId); + Assert.Equal("preview-key", built.PreviewApiKey); + Assert.True(built.UsePreviewApi); + Assert.Equal("https://preview.example.com/{0}", built.PreviewEndpoint); + Assert.Equal("mobile", built.DefaultRenditionPreset); + Assert.Equal("assets.example.com", built.CustomAssetDomain); + Assert.False(built.EnableResilience); + } + + // A value that differs from the property's default, so a property the copy skips fails the comparison. + private static object DistinctValueFor(PropertyInfo property) => property.PropertyType switch + { + var t when t == typeof(string) => $"copied-{property.Name}", + var t when t == typeof(bool) => true, + var t when t == typeof(int) => 42, + var t when Nullable.GetUnderlyingType(t) == typeof(int) => 42, + var t when t.IsEnum => Enum.GetValues(t).GetValue(Enum.GetValues(t).Length - 1)!, + _ => throw new NotSupportedException( + $"{property.Name} is a {property.PropertyType.Name}; add a distinct value for it here."), + }; +} diff --git a/src/delivery/Kontent.Ai.Delivery.Tests/Caching/HybridCacheFidelityTests.cs b/src/delivery/Kontent.Ai.Delivery.Tests/Caching/HybridCacheFidelityTests.cs new file mode 100644 index 000000000..982fa55a4 --- /dev/null +++ b/src/delivery/Kontent.Ai.Delivery.Tests/Caching/HybridCacheFidelityTests.cs @@ -0,0 +1,134 @@ +using AwesomeAssertions; +using Kontent.Ai.Delivery.Abstractions; +using Kontent.Ai.Delivery.Caching; +using Kontent.Ai.Delivery.ContentTypes; +using Kontent.Ai.Delivery.ContentTypes.Element; +using Kontent.Ai.Delivery.SharedModels; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; + +namespace Kontent.Ai.Delivery.Tests.Caching; + +/// +/// The distributed tier stores a serialized copy, so a hit off it is only as good as the round trip. +/// Content type elements are the one polymorphic shape that travels through it: a serializer that writes +/// them by their declared type drops the taxonomy group and the multiple-choice options without failing, +/// and the reader gets a base element that a consumer's cast to rejects. +/// +/// Two managers over one stand in for two nodes, which is what forces the +/// read to come off L2 - the writing node answers from its own memory tier and would never notice. +/// +/// +public class HybridCacheFidelityTests +{ + private readonly IDistributedCache _distributedCache; + + public HybridCacheFidelityTests() + { + var services = new ServiceCollection(); + services.AddDistributedMemoryCache(); + _distributedCache = services.BuildServiceProvider().GetRequiredService(); + } + + private HybridCacheManager Node() => new( + _distributedCache, + new DeliveryCacheOptions { DefaultExpiration = TimeSpan.FromMinutes(5) }); + + [Fact] + public async Task CachedContentType_KeepsTaxonomyAndMultipleChoiceData_WhenReadByAnotherNode() + { + var response = TypeListing(); + + using var writer = Node(); + var miss = await writer.GetOrSetAsync( + "types_fidelity", + _ => Task.FromResult?>( + new CacheEntry(response, ["types"]))); + + using var reader = Node(); + var hit = await reader.GetOrSetAsync( + "types_fidelity", + _ => Task.FromResult?>(null)); + + Assert.True(miss!.FromFactory); + Assert.NotNull(hit); + Assert.False(hit.FromFactory); + + var elements = ((IContentType)hit.Value.Types[0]).Elements; + + var taxonomy = Assert.IsAssignableFrom(elements["category"]); + Assert.Equal("categories", taxonomy.TaxonomyGroup); + + var multipleChoice = Assert.IsAssignableFrom(elements["rating"]); + Assert.Equal(["good", "bad"], multipleChoice.Options.Select(o => o.Codename)); + + // The plain element still round-trips, including the codename the dictionary key carries. + var text = elements["title"]; + Assert.Equal("text", text.Type); + Assert.Equal("Title", text.Name); + Assert.Equal("title", text.Codename); + } + + private static DeliveryTypeListingResponse TypeListing() => new() + { + Pagination = new Pagination { Skip = 0, Limit = 10, Count = 1, NextPageUrl = string.Empty }, + Types = + [ + new ContentType + { + System = new ContentTypeSystemAttributes + { + Id = Guid.Parse("11111111-1111-1111-1111-111111111111"), + Name = "Article", + Codename = "article", + LastModified = new DateTime(2026, 8, 10, 12, 0, 0, DateTimeKind.Utc) + }, + Elements = new Dictionary + { + ["title"] = new ContentElement { Type = "text", Name = "Title", Codename = "title" }, + ["category"] = new TaxonomyElement + { + Type = "taxonomy", + Name = "Category", + Codename = "category", + TaxonomyGroup = "categories" + }, + ["rating"] = new MultipleChoiceElement + { + Type = "multiple_choice", + Name = "Rating", + Codename = "rating", + Options = + [ + new MultipleChoiceOption { Name = "Good", Codename = "good" }, + new MultipleChoiceOption { Name = "Bad", Codename = "bad" } + ] + } + } + } + ] + }; + + [Fact] + public async Task TwoEnvironmentsSharingOneCache_DoNotSeeEachOthersEntries() + { + // The key is built from query parameters, so "the item article" hashes the same for every + // environment. Sharing one Redis between apps pointing at different environments then serves one + // app the other's content - the environment has to be part of the key, not just the query. + var options = new DeliveryCacheOptions { DefaultExpiration = TimeSpan.FromMinutes(5) }; + using var production = new HybridCacheManager(_distributedCache, options, environmentId: "11111111-1111-1111-1111-111111111111"); + using var staging = new HybridCacheManager(_distributedCache, options, environmentId: "22222222-2222-2222-2222-222222222222"); + + var fromProduction = await production.GetOrSetAsync( + "items:article", + _ => Task.FromResult?>(new CacheEntry("production copy", []))); + + var fromStaging = await staging.GetOrSetAsync( + "items:article", + _ => Task.FromResult?>(new CacheEntry("staging copy", []))); + + fromProduction!.Value.Should().Be("production copy"); + fromStaging!.Value.Should().Be("staging copy"); + fromStaging.FromFactory.Should().BeTrue("staging must miss rather than read production's entry"); + } +} diff --git a/src/delivery/Kontent.Ai.Delivery.Tests/Extensions/ServiceCollectionsExtensionsTests.cs b/src/delivery/Kontent.Ai.Delivery.Tests/Extensions/ServiceCollectionsExtensionsTests.cs index 047837153..b37f3b80f 100644 --- a/src/delivery/Kontent.Ai.Delivery.Tests/Extensions/ServiceCollectionsExtensionsTests.cs +++ b/src/delivery/Kontent.Ai.Delivery.Tests/Extensions/ServiceCollectionsExtensionsTests.cs @@ -9,6 +9,8 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Polly; +using Polly.Retry; namespace Kontent.Ai.Delivery.Tests.Extensions; @@ -409,17 +411,30 @@ public void AddDeliveryClient_WithNameContainingSpaces_ThrowsArgumentException() var exception = Assert.Throws(() => _serviceCollection.AddDeliveryClient("name with spaces", o => o.EnvironmentId = EnvironmentId)); - Assert.Contains("Client name cannot contain leading/trailing whitespace, or contain spaces", exception.Message); + Assert.Contains("Client name cannot contain whitespace", exception.Message); Assert.Contains("Use underscores or hyphens instead", exception.Message); } + // A tab is as invisible at the point of failure as a space, and the previous rule - trim plus a + // space check - let it through, so the name silently differed from the one used at resolution. + [Theory] + [InlineData("name\twith\ttabs")] + [InlineData("name\nwith\nnewlines")] + public void AddDeliveryClient_WithNameContainingOtherWhitespace_ThrowsArgumentException(string name) + { + var exception = Assert.Throws(() => + _serviceCollection.AddDeliveryClient(name, o => o.EnvironmentId = EnvironmentId)); + + Assert.Contains("Client name cannot contain whitespace", exception.Message); + } + [Fact] public void AddDeliveryClient_WithNameWithLeadingWhitespace_ThrowsArgumentException() { var exception = Assert.Throws(() => _serviceCollection.AddDeliveryClient(" leading-space", o => o.EnvironmentId = EnvironmentId)); - Assert.Contains("Client name cannot contain leading/trailing whitespace, or contain spaces", exception.Message); + Assert.Contains("Client name cannot contain whitespace", exception.Message); } [Fact] @@ -669,7 +684,7 @@ public void AddDeliveryMemoryCache_AfterCustomCacheManager_ReplacesPreviousCache } [Fact] - public async Task AddDeliveryMemoryCache_DefaultClient_AdvancedOverload_UsesUnprefixedNamespace() + public async Task AddDeliveryMemoryCache_DefaultClient_AddsNoClientNameToTheKeys() { _serviceCollection.AddDeliveryClient(o => { @@ -684,13 +699,16 @@ public async Task AddDeliveryMemoryCache_DefaultClient_AdvancedOverload_UsesUnpr var provider = _serviceCollection.BuildServiceProvider(); var manager = provider.GetRequiredKeyedService("Default"); var sharedMemoryCache = provider.GetRequiredService(); + // Same environment, no client name: the default client adds nothing of its own, so this manager + // sees its entries. A named client would prefix them and this would miss. using var unprefixedManager = new MemoryCacheManager( sharedMemoryCache, new DeliveryCacheOptions { KeyPrefix = string.Empty, DefaultExpiration = TimeSpan.FromMinutes(5) - }); + }, + environmentId: EnvironmentId); await manager.GetOrSetAsync( "prefix-check", @@ -1279,4 +1297,54 @@ private int CountCacheManagerRegistrations(string clientName) d.ServiceType == typeof(IDeliveryCacheManager) && Equals(d.ServiceKey, clientName)); } + + // The HttpClient ceiling is the only thing bounding a request when the default resilience pipeline - + // which carries the per-attempt timeout - is not the one installed. + + [Fact] + public void AddDeliveryClient_DefaultResilience_LiftsTheHttpClientCeiling() + { + _serviceCollection.AddDeliveryClient("production", o => o.EnvironmentId = EnvironmentId); + + Assert.Equal(Timeout.InfiniteTimeSpan, ResolveHttpClientTimeout("production")); + } + + [Fact] + public void AddDeliveryClient_ResilienceDisabled_StillBoundsTheRequest() + { + _serviceCollection.AddDeliveryClient("production", o => + { + o.EnvironmentId = EnvironmentId; + o.EnableResilience = false; + }); + + var timeout = ResolveHttpClientTimeout("production"); + + Assert.NotEqual(Timeout.InfiniteTimeSpan, timeout); + Assert.True(timeout > TimeSpan.Zero); + } + + [Fact] + public void AddDeliveryClient_CustomResilience_StillBoundsTheRequest() + { + _serviceCollection.AddDeliveryClient( + "production", + o => o.EnvironmentId = EnvironmentId, + configureResilience: builder => builder.AddRetry(new RetryStrategyOptions())); + + var timeout = ResolveHttpClientTimeout("production"); + + Assert.NotEqual(Timeout.InfiniteTimeSpan, timeout); + Assert.True(timeout > TimeSpan.Zero); + } + + private TimeSpan ResolveHttpClientTimeout(string clientName) + { + var provider = _serviceCollection.BuildServiceProvider(); + + using var httpClient = provider.GetRequiredService() + .CreateClient($"Kontent.Ai.Delivery.HttpClient.{clientName}"); + + return httpClient.Timeout; + } } diff --git a/src/delivery/Kontent.Ai.Delivery.Tests/Extensions/SharedJsonOptionsTests.cs b/src/delivery/Kontent.Ai.Delivery.Tests/Extensions/SharedJsonOptionsTests.cs new file mode 100644 index 000000000..a8cef4f7e --- /dev/null +++ b/src/delivery/Kontent.Ai.Delivery.Tests/Extensions/SharedJsonOptionsTests.cs @@ -0,0 +1,124 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Kontent.Ai.Delivery.Abstractions; +using Kontent.Ai.Delivery.Configuration; +using Kontent.Ai.Delivery.ContentItems; +using Microsoft.Extensions.DependencyInjection; + +namespace Kontent.Ai.Delivery.Tests.Extensions; + +/// +/// Registering is an ordinary thing for an application to do, and it +/// must not reach the SDK's deserializer. One that arrives without ContentItemConverterFactory +/// captures no raw item JSON, so hydration finds nothing and typed models come back empty - a failure +/// with no exception and no log line to follow. +/// +public class SharedJsonOptionsTests +{ + private const string EnvironmentId = "d79786fb-042c-47ec-8e5c-beaf93e38b84"; + + [Fact] + public void ApplicationRegisteredInstance_IsNotAdoptedAsTheSdkSerializer() + { + var applicationOptions = new JsonSerializerOptions { WriteIndented = true }; + + var provider = BuildProvider(services => services.AddSingleton(applicationOptions)); + + Assert.NotSame(applicationOptions, ResolveSdkOptions(provider)); + } + + [Fact] + public void ApplicationRegisteredFactory_IsNotAdoptedAsTheSdkSerializer() + { + // A factory registration hides the instance from descriptor inspection, which used to leave Refit + // and the mappers on two different serializers rather than one wrong one. + var applicationOptions = new JsonSerializerOptions { WriteIndented = true }; + + var provider = BuildProvider(services => services.AddSingleton(_ => applicationOptions)); + + Assert.NotSame(applicationOptions, ResolveSdkOptions(provider)); + } + + [Fact] + public void ApplicationKeyedRegistration_IsLeftAlone() + { + var applicationOptions = new JsonSerializerOptions { WriteIndented = true }; + + var provider = BuildProvider(services => services.AddKeyedSingleton("app", applicationOptions)); + + Assert.NotSame(applicationOptions, ResolveSdkOptions(provider)); + Assert.Same(applicationOptions, provider.GetRequiredKeyedService("app")); + } + + [Fact] + public void ApplicationRegistration_IsStillTheOneTheApplicationResolves() + { + var applicationOptions = new JsonSerializerOptions { WriteIndented = true }; + + var provider = BuildProvider(services => services.AddSingleton(applicationOptions)); + + Assert.Same(applicationOptions, provider.GetRequiredService()); + } + + [Fact] + public void SdkSerializer_CarriesTheConvertersHydrationDependsOn() + { + var options = ResolveSdkOptions(BuildProvider()); + + Assert.Contains(options.Converters, c => c is JsonConverterFactory); + } + + [Fact] + public void Deserializer_StillCapturesRawItemJson_WhenTheApplicationRegistersItsOwnOptions() + { + // The consequence the wiring exists for: no captured raw JSON means hydration has nothing to map + // from, and a typed model comes back empty without anything being thrown or logged. + var provider = BuildProvider(services => services.AddSingleton(new JsonSerializerOptions())); + + var item = provider.GetRequiredService() + .DeserializeContentItem(ItemJson, typeof(IDynamicElements)); + + Assert.True(((IRawContentItem)item).RawItemJson.HasValue); + } + + [Fact] + public void SecondClient_SharesTheFirstClientsOptionsInstance() + { + var services = new ServiceCollection(); + services.AddDeliveryClient("first", o => o.EnvironmentId = EnvironmentId); + services.AddDeliveryClient("second", o => o.EnvironmentId = EnvironmentId); + + // One instance keeps System.Text.Json's per-options metadata cache shared across clients. + Assert.NotNull(ResolveSdkOptions(services.BuildServiceProvider())); + } + + private static ServiceProvider BuildProvider(Action? registerApplicationServices = null) + { + var services = new ServiceCollection(); + registerApplicationServices?.Invoke(services); + services.AddDeliveryClient(o => o.EnvironmentId = EnvironmentId); + + return services.BuildServiceProvider(); + } + + private static JsonSerializerOptions ResolveSdkOptions(IServiceProvider provider) + => provider.GetRequiredService().Value; + + private const string ItemJson = """ + { + "system": { + "id": "00000000-0000-0000-0000-000000000001", + "name": "Test", + "codename": "test", + "type": "article", + "collection": "default", + "workflow": "default", + "workflow_step": "published", + "language": "en-US", + "last_modified": "2024-01-01T00:00:00Z", + "sitemap_locations": [] + }, + "elements": {} + } + """; +} diff --git a/src/delivery/Kontent.Ai.Delivery.Tests/QueryBuilders/PaginationIntegrationTests.cs b/src/delivery/Kontent.Ai.Delivery.Tests/QueryBuilders/PaginationIntegrationTests.cs index 0797b757b..a00cc9179 100644 --- a/src/delivery/Kontent.Ai.Delivery.Tests/QueryBuilders/PaginationIntegrationTests.cs +++ b/src/delivery/Kontent.Ai.Delivery.Tests/QueryBuilders/PaginationIntegrationTests.cs @@ -133,6 +133,39 @@ public async Task ItemListing_DynamicQuery_FetchPageByPage_WorksCorrectly() mockHttp.VerifyNoOutstandingExpectation(); } + [Fact] + public async Task ItemListing_DynamicQuery_FetchNextPage_CarriesDependencyKeys() + { + var env = Guid.NewGuid().ToString(); + var itemsUrl = $"https://deliver.kontent.ai/{env}/items"; + + var mockHttp = new MockHttpMessageHandler(); + mockHttp.Expect(itemsUrl) + .WithQueryString("limit", "1") + .Respond("application/json", BuildItemsListingJson(skip: 0, limit: 1, totalCount: 2, codenames: ["dyn_first"], hasNextPage: true)); + mockHttp.Expect(itemsUrl) + .WithQueryString("skip", "1") + .WithQueryString("limit", "1") + .Respond("application/json", BuildItemsListingJson(skip: 1, limit: 1, totalCount: 2, codenames: ["dyn_second"])); + + var client = BuildClient(env, mockHttp); + + var firstPage = await client.GetItems().Limit(1).ExecuteAsync(); + Assert.True(firstPage.IsSuccess); + Assert.NotNull(firstPage.DependencyKeys); + Assert.Contains("item_dyn_first", firstPage.DependencyKeys); + + var secondPage = await firstPage.Value.FetchNextPageAsync(); + Assert.NotNull(secondPage); + Assert.True(secondPage.IsSuccess); + + Assert.NotNull(secondPage.DependencyKeys); + Assert.Contains(DeliveryCacheDependencies.ItemsListScope, secondPage.DependencyKeys); + Assert.Contains("item_dyn_second", secondPage.DependencyKeys); + + mockHttp.VerifyNoOutstandingExpectation(); + } + [Fact] public async Task ItemListing_SingleItem_HasNextPage_IsFalse() { diff --git a/src/delivery/Kontent.Ai.Delivery.Tests/RetryPolicy/ResiliencePipelineTests.cs b/src/delivery/Kontent.Ai.Delivery.Tests/RetryPolicy/ResiliencePipelineTests.cs new file mode 100644 index 000000000..e5360c2fa --- /dev/null +++ b/src/delivery/Kontent.Ai.Delivery.Tests/RetryPolicy/ResiliencePipelineTests.cs @@ -0,0 +1,27 @@ +using AwesomeAssertions; +using Kontent.Ai.Common.Http; +using Polly.Timeout; + +namespace Kontent.Ai.Delivery.Tests.RetryPolicy; + +public class ResiliencePipelineTests +{ + // The default pipeline is retry-outside, timeout-inside, so a hung attempt reaches the retry as a + // TimeoutRejectedException. Sync pins the composed behaviour; here the shared predicate itself. + [Fact] + public void IsTransientException_TimeoutRejectedException_ReturnsTrue() + { + HttpRetryPredicates.IsTransientException(new TimeoutRejectedException(), CancellationToken.None) + .Should().BeTrue(); + } + + [Fact] + public void IsTransientException_CallerCancellation_ReturnsFalse() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + HttpRetryPredicates.IsTransientException(new OperationCanceledException(), cts.Token) + .Should().BeFalse(); + } +} diff --git a/src/delivery/Kontent.Ai.Delivery.Tests/RichText/RichTextIntegrationTests.cs b/src/delivery/Kontent.Ai.Delivery.Tests/RichText/RichTextIntegrationTests.cs index 8321c724c..842271ffe 100644 --- a/src/delivery/Kontent.Ai.Delivery.Tests/RichText/RichTextIntegrationTests.cs +++ b/src/delivery/Kontent.Ai.Delivery.Tests/RichText/RichTextIntegrationTests.cs @@ -637,6 +637,60 @@ public async Task IntegrationTest_HtmlNodeResolver_ByTagName_CustomizeListItems( #region Predicate-Based HTML Node Resolver Tests + [Fact] + public async Task IntegrationTest_HtmlNodeResolver_PredicateRegisteredFirst_BeatsALaterTagResolver() + { + // Tag registrations used to be lifted into a lookup consulted before any predicate, so a tag + // resolver won however late it was registered - the opposite of the documented order. + var client = await CreateDeliveryClientAsync("on_roasts.json"); + + var resolver = new HtmlResolverBuilder() + .WithHtmlNodeResolver( + predicate: node => node.TagName == "h3", + resolver: async (node, resolveChildren) => $"

{await resolveChildren(node.Children)}

") + .WithHtmlNodeResolver("h3", async (node, resolveChildren) => $"

{await resolveChildren(node.Children)}

") + .Build(); + + var result = await client.GetItem
("on_roasts").ExecuteAsync(); + var html = await result.Value.Elements.BodyCopy.ToHtmlAsync(resolver); + + Assert.Contains("

", html); + Assert.DoesNotContain("class=\"tag\"", html); + } + + [Fact] + public async Task IntegrationTest_HtmlNodeResolver_DescriptionIsNotDispatch() + { + // The description is caller-supplied text. Dispatch used to be read out of it, so a predicate + // resolver described as "Tag=h3" was silently promoted into the tag lookup and jumped the queue. + var client = await CreateDeliveryClientAsync("on_roasts.json"); + + var resolver = new HtmlResolverBuilder() + .WithHtmlNodeResolver( + predicate: node => node.TagName == "h1", + resolver: async (node, resolveChildren) => $"

{await resolveChildren(node.Children)}

", + description: "Tag=h3") + .WithHtmlNodeResolver("h3", async (node, resolveChildren) => $"

{await resolveChildren(node.Children)}

") + .Build(); + + var result = await client.GetItem
("on_roasts").ExecuteAsync(); + var html = await result.Value.Elements.BodyCopy.ToHtmlAsync(resolver); + + Assert.Contains("

", html); + } + + [Fact] + public void HtmlResolver_DuplicateTagRegistration_DoesNotThrowAtBuild() + { + // Building threw an ArgumentException from the tag lookup, where every other registration is + // simply resolved by order. Duplicates now behave like any other conditional pair: first wins. + var builder = new HtmlResolverBuilder() + .WithHtmlNodeResolver("h3", (node, resolveChildren) => new ValueTask("first")) + .WithHtmlNodeResolver("h3", (node, resolveChildren) => new ValueTask("second")); + + Assert.Null(Record.Exception(() => builder.Build())); + } + [Fact] public async Task IntegrationTest_HtmlNodeResolver_WithMultiplePredicates_FirstMatchWins() { diff --git a/src/delivery/Kontent.Ai.Delivery.Tests/Serialization/ContentElementDictionaryConverterTests.cs b/src/delivery/Kontent.Ai.Delivery.Tests/Serialization/ContentElementDictionaryConverterTests.cs index 1908deba9..a3d4d1e09 100644 --- a/src/delivery/Kontent.Ai.Delivery.Tests/Serialization/ContentElementDictionaryConverterTests.cs +++ b/src/delivery/Kontent.Ai.Delivery.Tests/Serialization/ContentElementDictionaryConverterTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Kontent.Ai.Delivery.ContentTypes.Element; using Kontent.Ai.Delivery.Serialization.Converters; +using Kontent.Ai.Delivery.SharedModels; namespace Kontent.Ai.Delivery.Tests.Serialization; @@ -34,13 +35,37 @@ public void Read_ValidElements_HydratesCodenameFromKey() Assert.Equal("body", result["body"].Codename); } + // Writing exists for the distributed cache, which stores and re-reads these dictionaries. Anything the + // write drops is data a second node silently loses, so the assertion is on the round trip, not the call. [Fact] - public void Write_ThrowsNotSupportedException() + public void Write_RoundTripsEveryElementKind() { - var dict = new Dictionary() as IReadOnlyDictionary; + var dict = new Dictionary + { + ["title"] = new ContentElement { Type = "text", Name = "Title", Codename = "title" }, + ["category"] = new TaxonomyElement + { + Type = "taxonomy", + Name = "Category", + Codename = "category", + TaxonomyGroup = "categories" + }, + ["rating"] = new MultipleChoiceElement + { + Type = "multiple_choice", + Name = "Rating", + Codename = "rating", + Options = [new MultipleChoiceOption { Name = "Good", Codename = "good" }] + } + } as IReadOnlyDictionary; + + var json = JsonSerializer.Serialize(dict, Options); + var result = JsonSerializer.Deserialize>(json, Options)!; - Assert.Throws(() => - JsonSerializer.Serialize(dict, Options)); + Assert.Equal("text", result["title"].Type); + Assert.Equal("title", result["title"].Codename); + Assert.Equal("categories", Assert.IsType(result["category"]).TaxonomyGroup); + Assert.Equal("good", Assert.IsType(result["rating"]).Options[0].Codename); } private static JsonSerializerOptions CreateOptions() diff --git a/src/delivery/Kontent.Ai.Delivery/Api/QueryBuilders/DynamicItemQuery.cs b/src/delivery/Kontent.Ai.Delivery/Api/QueryBuilders/DynamicItemQuery.cs index 6a576f4a8..dc279cd0c 100644 --- a/src/delivery/Kontent.Ai.Delivery/Api/QueryBuilders/DynamicItemQuery.cs +++ b/src/delivery/Kontent.Ai.Delivery/Api/QueryBuilders/DynamicItemQuery.cs @@ -82,7 +82,9 @@ public async Task> ExecuteAsync(CancellationToken if (runtimeItem is not null) { - return DeliveryResult.SuccessFrom(runtimeItem, deliveryResult); + // Carried across explicitly: SuccessFrom projects the source's metadata but defaults the + // dependency keys to null, and these are what output-cache tagging is documented to use. + return DeliveryResult.SuccessFrom(runtimeItem, deliveryResult, deliveryResult.DependencyKeys); } } diff --git a/src/delivery/Kontent.Ai.Delivery/Api/QueryBuilders/DynamicItemsQuery.cs b/src/delivery/Kontent.Ai.Delivery/Api/QueryBuilders/DynamicItemsQuery.cs index d359fc9be..5d60ca85a 100644 --- a/src/delivery/Kontent.Ai.Delivery/Api/QueryBuilders/DynamicItemsQuery.cs +++ b/src/delivery/Kontent.Ai.Delivery/Api/QueryBuilders/DynamicItemsQuery.cs @@ -101,7 +101,10 @@ public async Task> ExecuteAsync(Ca var response = await ConvertResponseAsync(deliveryResult.Value, cancellationToken).ConfigureAwait(false); - return DeliveryResult.SuccessFrom>(response, deliveryResult); + // Carried across explicitly: SuccessFrom projects the source's metadata but defaults the dependency + // keys to null, and these are what output-cache tagging is documented to use. + return DeliveryResult.SuccessFrom>( + response, deliveryResult, deliveryResult.DependencyKeys); } private async Task ConvertResponseAsync( @@ -140,7 +143,8 @@ private async Task> FetchNextPageA var response = await ConvertResponseAsync(nextPageResult.Value, cancellationToken).ConfigureAwait(false); - return DeliveryResult.SuccessFrom>(response, nextPageResult); + return DeliveryResult.SuccessFrom>( + response, nextPageResult, nextPageResult.DependencyKeys); } private static Pagination ToPagination(IPagination pagination) => new() diff --git a/src/delivery/Kontent.Ai.Delivery/Api/QueryBuilders/ItemQuery.cs b/src/delivery/Kontent.Ai.Delivery/Api/QueryBuilders/ItemQuery.cs index a22ceb133..aeba99410 100644 --- a/src/delivery/Kontent.Ai.Delivery/Api/QueryBuilders/ItemQuery.cs +++ b/src/delivery/Kontent.Ai.Delivery/Api/QueryBuilders/ItemQuery.cs @@ -22,7 +22,6 @@ internal sealed class ItemQuery( ILogger? logger = null) : IItemQuery, ICacheExpirationConfigurable { private readonly QueryLoggingHelper _log = new(logger, "Item", codename); - private readonly SerializedFilterCollection _serializedFilters = []; private SingleItemParams _params = new(); private bool _waitForLoadingNewContent; public TimeSpan? CacheExpiration { get; set; } @@ -122,6 +121,8 @@ private async Task>> ExecuteWithCacheAsync( Action>> captureApiResult, CancellationToken cancellationToken) { + IContentItem? hydratedHere = null; + var cached = await cacheManager.GetOrSetAsync( cacheKey, async ct => @@ -132,6 +133,7 @@ private async Task>> ExecuteWithCacheAsync( return null; var (item, deps) = await ProcessItemAsync(result.Value, ct).ConfigureAwait(false); + hydratedHere = item; var rawPayload = CachedRawItemsPayload.FromItem(item, result.Value.ModularContent); return new CacheEntry(rawPayload, deps); }, @@ -141,6 +143,15 @@ private async Task>> ExecuteWithCacheAsync( if (cached is null) return null; + // Hydrating a miss twice is what this avoids: the factory already built the value in order to + // collect the dependency keys, and rehydrating parses and maps the very same payload again. Only + // this call's own factory result can be reused - FromFactory is false for a cache hit and for a + // background refresh, both of which still rehydrate. + if (cached.FromFactory && hydratedHere is not null) + { + return new CacheResult>(hydratedHere, cached.DependencyKeys) { FromFactory = true }; + } + var item = await CachePayloadHelper.RehydrateItemAsync( cached.Value, contentDeserializer, @@ -209,7 +220,8 @@ private async Task>> FetchFromApiAs var rawResponse = await api.GetItemInternalAsync( codename, _params, - FilterQueryString.Render(_serializedFilters), + // A single-item query carries no filters; the parameter exists for the listing endpoints. + filters: null, waitForLoadingNewContent, cancellationToken) .ConfigureAwait(false); diff --git a/src/delivery/Kontent.Ai.Delivery/Api/QueryBuilders/ItemsQuery.cs b/src/delivery/Kontent.Ai.Delivery/Api/QueryBuilders/ItemsQuery.cs index 3648adc08..313c46f33 100644 --- a/src/delivery/Kontent.Ai.Delivery/Api/QueryBuilders/ItemsQuery.cs +++ b/src/delivery/Kontent.Ai.Delivery/Api/QueryBuilders/ItemsQuery.cs @@ -165,6 +165,8 @@ private async Task>> Execut Action>> captureApiResult, CancellationToken cancellationToken) { + DeliveryItemListingResponse? hydratedHere = null; + var cached = await cacheManager.GetOrSetAsync( cacheKey, async ct => @@ -175,6 +177,7 @@ private async Task>> Execut return null; var (response, deps) = await ProcessItemsAsync(result.Value, ct).ConfigureAwait(false); + hydratedHere = response; var rawPayload = CachedRawItemsPayload.FromListing(response); return new CacheEntry(rawPayload, deps); }, @@ -184,6 +187,15 @@ private async Task>> Execut if (cached is null) return null; + // Hydrating a miss twice is what this avoids: the factory already built the value in order to + // collect the dependency keys, and rehydrating parses and maps the very same payload again. Only + // this call's own factory result can be reused - FromFactory is false for a cache hit and for a + // background refresh, both of which still rehydrate. + if (cached.FromFactory && hydratedHere is not null) + { + return new CacheResult>(hydratedHere, cached.DependencyKeys) { FromFactory = true }; + } + var response = await CachePayloadHelper.RehydrateListingAsync( cached.Value, contentDeserializer, diff --git a/src/delivery/Kontent.Ai.Delivery/Configuration/DeliveryClientBuilder.cs b/src/delivery/Kontent.Ai.Delivery/Configuration/DeliveryClientBuilder.cs index 6cceaa287..3b714f30d 100644 --- a/src/delivery/Kontent.Ai.Delivery/Configuration/DeliveryClientBuilder.cs +++ b/src/delivery/Kontent.Ai.Delivery/Configuration/DeliveryClientBuilder.cs @@ -156,8 +156,10 @@ public DeliveryClientBuilder WithLoggerFactory(ILoggerFactory loggerFactory) /// Builds and returns a configured instance. /// /// A fully configured that should be disposed when no longer needed. - /// - /// Thrown when validation fails (e.g., missing environment ID or API key). + /// + /// The configured options are invalid - a missing or malformed environment ID, a preview or secure + /// access key that does not match its flag. The validation runs in the options pipeline, so this is + /// what surfaces rather than an exception from the builder itself. /// /// /// @@ -192,7 +194,8 @@ public DeliveryClient Build() var services = new ServiceCollection(); BuildServices(services); - // Validate options and build dependencies (options validation happens during provider build due to ValidateOnStart) + // ValidateOnBuild resolves every registered service, which runs the options validation with it. + // ValidateOnStart would not fire here - that needs a host, and this path builds a bare provider. var serviceProvider = services.BuildServiceProvider( new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true }); diff --git a/src/delivery/Kontent.Ai.Delivery/Configuration/DeliveryJsonOptions.cs b/src/delivery/Kontent.Ai.Delivery/Configuration/DeliveryJsonOptions.cs new file mode 100644 index 000000000..7d9342f3a --- /dev/null +++ b/src/delivery/Kontent.Ai.Delivery/Configuration/DeliveryJsonOptions.cs @@ -0,0 +1,20 @@ +using System.Text.Json; + +namespace Kontent.Ai.Delivery.Configuration; + +/// +/// Holds the the SDK reads the wire with, under a service type only +/// this assembly names. +/// +/// +/// itself is a service type that belongs to the application, and +/// registering an instance of it is ordinary. Sharing that type with the SDK made whichever registration +/// came first win: an application's own options replaced the SDK's, and without +/// ContentItemConverterFactory no raw item JSON is captured, so hydration finds nothing and typed +/// models deserialize empty - with no error anywhere. A private type keeps the two apart, and the +/// application's registration means what it says again. +/// +internal sealed class DeliveryJsonOptions(JsonSerializerOptions value) +{ + public JsonSerializerOptions Value { get; } = value; +} diff --git a/src/delivery/Kontent.Ai.Delivery/Configuration/DeliveryOptionsBuilder.cs b/src/delivery/Kontent.Ai.Delivery/Configuration/DeliveryOptionsBuilder.cs index 142fd9705..6917ad743 100644 --- a/src/delivery/Kontent.Ai.Delivery/Configuration/DeliveryOptionsBuilder.cs +++ b/src/delivery/Kontent.Ai.Delivery/Configuration/DeliveryOptionsBuilder.cs @@ -1,3 +1,5 @@ +using Kontent.Ai.Common; + namespace Kontent.Ai.Delivery.Configuration; /// @@ -14,7 +16,7 @@ private DeliveryOptionsBuilder() { } public static IDeliveryOptionsBuilder CreateInstance() => new DeliveryOptionsBuilder(); /// - /// Creates a new instance of the class with the specified environment ID. + /// Sets the Kontent.ai environment the client reads from. /// /// The identifier of a Kontent.ai environment. public IDeliveryOptionsBuilder WithEnvironmentId(string environmentId) @@ -24,7 +26,7 @@ public IDeliveryOptionsBuilder WithEnvironmentId(string environmentId) } /// - /// Creates a new instance of the class with the specified environment ID. + /// Sets the Kontent.ai environment the client reads from. /// /// The identifier of a Kontent.ai environment. public IDeliveryOptionsBuilder WithEnvironmentId(Guid environmentId) @@ -172,17 +174,15 @@ private void SetCustomEndpoint(string endpoint) /// /// Returns a new instance of the class. /// - public DeliveryOptions Build() => new() + /// + /// Reflected rather than listed property by property: a hand-written list keeps compiling when a new + /// option is added and silently stops carrying it, so a value the caller set never reaches the client. + /// + public DeliveryOptions Build() { - EnvironmentId = _options.EnvironmentId, - EnableResilience = _options.EnableResilience, - ProductionEndpoint = _options.ProductionEndpoint, - PreviewEndpoint = _options.PreviewEndpoint, - PreviewApiKey = _options.PreviewApiKey, - UsePreviewApi = _options.UsePreviewApi, - UseSecureAccess = _options.UseSecureAccess, - SecureAccessApiKey = _options.SecureAccessApiKey, - DefaultRenditionPreset = _options.DefaultRenditionPreset, - CustomAssetDomain = _options.CustomAssetDomain - }; + var built = new DeliveryOptions(); + OptionsCopier.Copy(_options, built); + + return built; + } } diff --git a/src/delivery/Kontent.Ai.Delivery/ContentItems/ContentLinks/ContentLink.cs b/src/delivery/Kontent.Ai.Delivery/ContentItems/ContentLinks/ContentLink.cs index 9083d9271..9e86e3d9c 100644 --- a/src/delivery/Kontent.Ai.Delivery/ContentItems/ContentLinks/ContentLink.cs +++ b/src/delivery/Kontent.Ai.Delivery/ContentItems/ContentLinks/ContentLink.cs @@ -12,7 +12,7 @@ internal sealed record ContentLink : IContentLink /// Populated from the dictionary key when deserializing rich text links. /// [JsonIgnore] - public Guid Id { get; set; } + public Guid Id { get; init; } /// [JsonPropertyName("codename")] diff --git a/src/delivery/Kontent.Ai.Delivery/ContentItems/Elements/RichTextElementEnvelopeReader.cs b/src/delivery/Kontent.Ai.Delivery/ContentItems/Elements/RichTextElementEnvelopeReader.cs index 489d27063..26391590e 100644 --- a/src/delivery/Kontent.Ai.Delivery/ContentItems/Elements/RichTextElementEnvelopeReader.cs +++ b/src/delivery/Kontent.Ai.Delivery/ContentItems/Elements/RichTextElementEnvelopeReader.cs @@ -83,8 +83,7 @@ private static Dictionary DeserializeContentLinks( continue; } - link.Id = id; - result[id] = link; + result[id] = link with { Id = id }; } return result; diff --git a/src/delivery/Kontent.Ai.Delivery/ContentItems/Mapping/ElementValueMapper.cs b/src/delivery/Kontent.Ai.Delivery/ContentItems/Mapping/ElementValueMapper.cs index 8f5964af0..707a6f5f0 100644 --- a/src/delivery/Kontent.Ai.Delivery/ContentItems/Mapping/ElementValueMapper.cs +++ b/src/delivery/Kontent.Ai.Delivery/ContentItems/Mapping/ElementValueMapper.cs @@ -253,7 +253,7 @@ private static Asset CreateAsset(JsonElement assetElement, string? defaultPreset private static Dictionary ParseRenditions(JsonElement assetElement) { if (!assetElement.TryGetProperty("renditions", out var rendsEl) || - rendsEl.ValueKind is JsonValueKind.Null or not JsonValueKind.Object) + rendsEl.ValueKind is not JsonValueKind.Object) { return new Dictionary(StringComparer.Ordinal); } diff --git a/src/delivery/Kontent.Ai.Delivery/ContentItems/Processing/RichTextParser.cs b/src/delivery/Kontent.Ai.Delivery/ContentItems/Processing/RichTextParser.cs index e2e1c6f38..6446e8899 100644 --- a/src/delivery/Kontent.Ai.Delivery/ContentItems/Processing/RichTextParser.cs +++ b/src/delivery/Kontent.Ai.Delivery/ContentItems/Processing/RichTextParser.cs @@ -30,7 +30,7 @@ internal sealed class RichTextParser( if (contentElement is not IRichTextElementValue element) return null; - var document = await parser.ParseDocumentAsync(element.Value, cancellationToken).ConfigureAwait(false); + using var document = await parser.ParseDocumentAsync(element.Value, cancellationToken).ConfigureAwait(false); if (document.Body is null) { diff --git a/src/delivery/Kontent.Ai.Delivery/ContentItems/RichText/Resolution/ConditionalHtmlNodeResolver.cs b/src/delivery/Kontent.Ai.Delivery/ContentItems/RichText/Resolution/ConditionalHtmlNodeResolver.cs index 64f338fa2..2a380e783 100644 --- a/src/delivery/Kontent.Ai.Delivery/ContentItems/RichText/Resolution/ConditionalHtmlNodeResolver.cs +++ b/src/delivery/Kontent.Ai.Delivery/ContentItems/RichText/Resolution/ConditionalHtmlNodeResolver.cs @@ -3,7 +3,17 @@ namespace Kontent.Ai.Delivery.ContentItems.RichText.Resolution; /// /// Internal record representing a conditional resolver for HTML nodes. /// +/// Decides whether this resolver handles a node. +/// Renders the node. +/// Free-text label, for diagnostics only. +/// +/// Set when the resolver was registered for a specific tag, so matching can compare the name instead of +/// invoking . Carried as its own field rather than encoded into +/// : a description is caller-supplied text, and reading dispatch out of it +/// meant any resolver whose description happened to start with the magic prefix was treated as a tag one. +/// internal sealed record ConditionalHtmlNodeResolver( HtmlNodePredicate Predicate, BlockResolver Resolver, - string? Description = null); + string? Description = null, + string? TagName = null); diff --git a/src/delivery/Kontent.Ai.Delivery/ContentItems/RichText/Resolution/HtmlResolver.cs b/src/delivery/Kontent.Ai.Delivery/ContentItems/RichText/Resolution/HtmlResolver.cs index 708609051..b2c5fb134 100644 --- a/src/delivery/Kontent.Ai.Delivery/ContentItems/RichText/Resolution/HtmlResolver.cs +++ b/src/delivery/Kontent.Ai.Delivery/ContentItems/RichText/Resolution/HtmlResolver.cs @@ -10,7 +10,6 @@ internal sealed class HtmlResolver : IHtmlResolver private readonly HtmlResolverOptions _options; // Performance cache: maps tag names to their dedicated resolvers for O(1) lookup - private readonly FrozenDictionary> _tagResolverCache; // Codename-based resolvers for embedded content (components/linked items) private readonly FrozenDictionary>> _embeddedContentResolvers; @@ -32,14 +31,6 @@ public HtmlResolver( _resolvers = resolvers ?? throw new ArgumentNullException(nameof(resolvers)); _options = options ?? throw new ArgumentNullException(nameof(options)); - // Build immutable tag resolver cache - _tagResolverCache = _options.ConditionalHtmlNodeResolvers - .Where(c => c.Description?.StartsWith("Tag=") == true) - .ToFrozenDictionary( - c => c.Description![4..], // Extract tag name from "Tag=..." description - c => c.Resolver, - StringComparer.OrdinalIgnoreCase); - // Build immutable embedded content resolver cache (codename-based dispatch) _embeddedContentResolvers = options.EmbeddedContentResolvers?.ToFrozenDictionary( kvp => kvp.Key, @@ -147,28 +138,27 @@ private static bool TryGetEmbeddedContentModelType(IEmbeddedContent content, out private async ValueTask ResolveHtmlNodeAsync(IHtmlNode node) { - // Step 1: Check tag resolver cache for O(1) lookup - if (_tagResolverCache.TryGetValue(node.TagName, out var cachedResolver)) - { - return await cachedResolver(node, ResolveChildrenAsync).ConfigureAwait(false); - } - - // Step 2: Evaluate conditional resolvers in registration order (first match wins) - var matchingResolver = _options.ConditionalHtmlNodeResolvers - .FirstOrDefault(c => c.Predicate(node)); + // Step 1: conditional resolvers in registration order - first match wins, tag registrations + // included. A tag match is a name comparison rather than a predicate call, which is what the + // separate lookup was for; keeping them in one pass is what makes the documented order true. + var matchingResolver = _options.ConditionalHtmlNodeResolvers.FirstOrDefault(Matches); if (matchingResolver is not null) { return await matchingResolver.Resolver(node, ResolveChildrenAsync).ConfigureAwait(false); } - // Step 3: Use default HTML node resolver if configured + // Step 2: Use default HTML node resolver if configured if (_options.DefaultHtmlNodeResolver is not null) { return await _options.DefaultHtmlNodeResolver(node, ResolveChildrenAsync).ConfigureAwait(false); } - // Step 4: Ultimate fallback - built-in default + // Step 3: Ultimate fallback - built-in default return await DefaultResolvers.HtmlElementResolver()(node, ResolveChildrenAsync).ConfigureAwait(false); + + bool Matches(ConditionalHtmlNodeResolver candidate) => candidate.TagName is { } tag + ? node.TagName.Equals(tag, StringComparison.OrdinalIgnoreCase) + : candidate.Predicate(node); } private async ValueTask ResolveChildrenAsync(IEnumerable children) diff --git a/src/delivery/Kontent.Ai.Delivery/ContentItems/RichText/Resolution/HtmlResolverBuilder.cs b/src/delivery/Kontent.Ai.Delivery/ContentItems/RichText/Resolution/HtmlResolverBuilder.cs index 9357c9ec7..90b942dcf 100644 --- a/src/delivery/Kontent.Ai.Delivery/ContentItems/RichText/Resolution/HtmlResolverBuilder.cs +++ b/src/delivery/Kontent.Ai.Delivery/ContentItems/RichText/Resolution/HtmlResolverBuilder.cs @@ -246,10 +246,13 @@ public IHtmlResolverBuilder WithHtmlNodeResolver( ArgumentException.ThrowIfNullOrWhiteSpace(tagName); ArgumentNullException.ThrowIfNull(resolver); - return WithHtmlNodeResolver( + _conditionalHtmlNodeResolvers.Add(new ConditionalHtmlNodeResolver( node => node.TagName.Equals(tagName, StringComparison.OrdinalIgnoreCase), resolver, - $"Tag={tagName}"); + Description: $"Tag={tagName}", + TagName: tagName)); + + return this; } /// diff --git a/src/delivery/Kontent.Ai.Delivery/ContentItems/TypeProvider.cs b/src/delivery/Kontent.Ai.Delivery/ContentItems/TypeProvider.cs index 2156e41bf..754a7420b 100644 --- a/src/delivery/Kontent.Ai.Delivery/ContentItems/TypeProvider.cs +++ b/src/delivery/Kontent.Ai.Delivery/ContentItems/TypeProvider.cs @@ -57,12 +57,10 @@ internal sealed class TypeProvider : ITypeProvider } } - // 3. Fallback: check calling assembly (for test scenarios where entry assembly may be test runner). - // Note: Assembly.GetCallingAssembly() inside a Lazy callback resolves the call stack at - // Lazy evaluation time, which goes through the Lazy infrastructure rather than user code. - // In practice this still works because steps 1-2 (entry assembly + references) handle - // production scenarios, and step 3 is a fallback that happens to work in most xUnit setups. - // Users can always override by registering their own ITypeProvider in the DI container. + // 3. Fallback: the calling assembly, for tests where the entry assembly is the test runner. + // GetCallingAssembly inside a Lazy callback resolves through the Lazy infrastructure + // rather than user code, so this is best-effort; steps 1-2 cover production, and + // registering an ITypeProvider in DI overrides all of it. var callingAssembly = Assembly.GetCallingAssembly(); if (callingAssembly is not null && callingAssembly != entryAssembly) { @@ -92,17 +90,10 @@ internal sealed class TypeProvider : ITypeProvider return (ITypeProvider?)Activator.CreateInstance(providerType); } } - // Deliberately broad. "No generated provider" is a supported state, not a failure - the - // caller falls back to dynamic types - so every way this can fail means the same thing: - // nothing usable in this assembly. Scanning arbitrary loaded assemblies can fail in many - // ways (unreflectable images, missing transitive dependencies, a provider constructor that - // throws), and enumerating them would only decide which failures degrade gracefully and - // which do not. - // - // Narrowing it would be actively harmful here: this runs inside a Lazy factory, which - // CACHES a thrown exception and rethrows it on every later access. An unlisted exception - // would therefore not surface once - it would break every subsequent item mapping for the - // lifetime of the process, instead of falling back to dynamic types. + // Deliberately broad: "no generated provider" is a supported state, so every way this can + // fail means the same thing - nothing usable here. Narrowing would be harmful, because a + // Lazy factory CACHES a thrown exception and rethrows it on every later access, breaking + // every subsequent item mapping instead of falling back to dynamic types. catch { return null; diff --git a/src/delivery/Kontent.Ai.Delivery/DeliverySourceTrackingHeaderAttribute.cs b/src/delivery/Kontent.Ai.Delivery/DeliverySourceTrackingHeaderAttribute.cs index cf9cf40c1..3914a272b 100644 --- a/src/delivery/Kontent.Ai.Delivery/DeliverySourceTrackingHeaderAttribute.cs +++ b/src/delivery/Kontent.Ai.Delivery/DeliverySourceTrackingHeaderAttribute.cs @@ -5,7 +5,7 @@ namespace Kontent.Ai.Delivery; /// See https://kontent-ai.github.io/articles/Guidelines-for-Kontent.ai-related-tools.html#analytics for more info. /// [AttributeUsage(AttributeTargets.Assembly)] -public class DeliverySourceTrackingHeaderAttribute : Attribute +public sealed class DeliverySourceTrackingHeaderAttribute : Attribute { /// /// Name of the package (e.g. Acme.Kontent.Ai.AwesomeTool) diff --git a/src/delivery/Kontent.Ai.Delivery/Extensions/ServiceCollectionExtensions.Dependencies.cs b/src/delivery/Kontent.Ai.Delivery/Extensions/ServiceCollectionExtensions.Dependencies.cs index e44da9fc0..5749793e9 100644 --- a/src/delivery/Kontent.Ai.Delivery/Extensions/ServiceCollectionExtensions.Dependencies.cs +++ b/src/delivery/Kontent.Ai.Delivery/Extensions/ServiceCollectionExtensions.Dependencies.cs @@ -1,10 +1,12 @@ using System.Text.Json; using AngleSharp.Html.Parser; +using Kontent.Ai.Delivery.Configuration; using Kontent.Ai.Delivery.ContentItems; using Kontent.Ai.Delivery.ContentItems.Mapping; using Kontent.Ai.Delivery.ContentItems.Processing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; namespace Kontent.Ai.Delivery; @@ -12,14 +14,20 @@ public static partial class ServiceCollectionExtensions { private static void RegisterDependencies(IServiceCollection services, JsonSerializerOptions sharedJsonOptions) { - // JSON serialization (shared instance — same one used by Refit) - services.TryAddSingleton(sharedJsonOptions); + // JSON serialization (shared instance — same one used by Refit). Held under an SDK-private type so + // the application's own JsonSerializerOptions registration stays the application's. + services.TryAddSingleton(new DeliveryJsonOptions(sharedJsonOptions)); // Core services services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); + services.TryAddSingleton(sp => + new ContentDeserializer(sp.GetRequiredService().Value)); + services.TryAddSingleton(sp => new ElementValueMapper( + sp.GetRequiredService(), + sp.GetRequiredService().Value, + sp.GetRequiredService(), + sp.GetService>())); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/src/delivery/Kontent.Ai.Delivery/Extensions/ServiceCollectionExtensions.HttpClient.cs b/src/delivery/Kontent.Ai.Delivery/Extensions/ServiceCollectionExtensions.HttpClient.cs index ae05e53f7..a4074cc3d 100644 --- a/src/delivery/Kontent.Ai.Delivery/Extensions/ServiceCollectionExtensions.HttpClient.cs +++ b/src/delivery/Kontent.Ai.Delivery/Extensions/ServiceCollectionExtensions.HttpClient.cs @@ -35,11 +35,17 @@ private static void RegisterNamedHttpClient( // The DeliveryAuthenticationHandler handles runtime endpoint switching. httpClient.BaseAddress = new Uri(options.GetBaseUrl(), UriKind.Absolute); - // The resilience pipeline owns timing: it bounds each attempt (see ConfigureDefaultResilience) - // and therefore the whole call. HttpClient's own 100-second default applies to the entire - // SendAsync - retries and backoff included - so it silently clipped the last attempt of a - // pipeline that is allowed to take longer than that. - httpClient.Timeout = System.Threading.Timeout.InfiniteTimeSpan; + // Timing is the pipeline's job only when the pipeline is the SDK's own: that one bounds every + // attempt (see ConfigureDefaultResilience), while HttpClient's 100-second ceiling covers the + // whole SendAsync - retries and backoff included - and so would clip a pipeline legitimately + // allowed to run longer. Nothing else bounds a request, so with resilience disabled, or with a + // caller-supplied pipeline whose shape we cannot know, that ceiling stays and a black-holed + // connection fails rather than hanging the caller forever. A caller who needs longer than the + // ceiling raises it through configureHttpClient, which is applied after this. + if (options.EnableResilience && configureResilience is null) + { + httpClient.Timeout = System.Threading.Timeout.InfiniteTimeSpan; + } }); // Add resilience and message handlers diff --git a/src/delivery/Kontent.Ai.Delivery/Extensions/ServiceCollectionExtensions.cs b/src/delivery/Kontent.Ai.Delivery/Extensions/ServiceCollectionExtensions.cs index 929f820a6..c3464c58f 100644 --- a/src/delivery/Kontent.Ai.Delivery/Extensions/ServiceCollectionExtensions.cs +++ b/src/delivery/Kontent.Ai.Delivery/Extensions/ServiceCollectionExtensions.cs @@ -465,20 +465,21 @@ internal static DeliveryClient CreateDeliveryClient( } /// - /// Returns the shared instance already registered in - /// the service collection, or creates and registers a new one. This ensures Refit and - /// internal SDK mappers operate on the same options instance. + /// Returns the options a previously registered client already shares, or creates the SDK's own, so + /// that Refit and the internal mappers read the wire through one instance. /// + /// + /// Keyed on rather than on : the + /// bare type belongs to the application, and adopting whatever it had registered there gave the SDK a + /// serializer without its own converters - see . + /// private static JsonSerializerOptions GetOrCreateSharedJsonOptions(IServiceCollection services) { - var existing = services.FirstOrDefault(d => - d.ServiceType == typeof(JsonSerializerOptions) && - d.Lifetime == ServiceLifetime.Singleton); - - if (existing?.ImplementationInstance is JsonSerializerOptions opts) - return opts; + var existing = services.FirstOrDefault(d => d.ServiceType == typeof(DeliveryJsonOptions)); - return RefitSettingsProvider.CreateDefaultJsonSerializerOptions(); + return existing?.ImplementationInstance is DeliveryJsonOptions registered + ? registered.Value + : RefitSettingsProvider.CreateDefaultJsonSerializerOptions(); } private static string GetHttpClientName(string name) => $"{HttpClientNamePrefix}{name}"; diff --git a/src/delivery/Kontent.Ai.Delivery/GlobalUsings.cs b/src/delivery/Kontent.Ai.Delivery/GlobalUsings.cs index cc12b1155..68504d76f 100644 --- a/src/delivery/Kontent.Ai.Delivery/GlobalUsings.cs +++ b/src/delivery/Kontent.Ai.Delivery/GlobalUsings.cs @@ -1,8 +1,3 @@ -global using System; -global using System.Collections.Generic; -global using System.Linq; -global using System.Net.Http; -global using System.Threading.Tasks; global using Kontent.Ai.Delivery.Abstractions; global using Kontent.Ai.Delivery.Api; global using Kontent.Ai.Delivery.Api.QueryBuilders; diff --git a/src/delivery/Kontent.Ai.Delivery/Serialization/Converters/ContentElementConverter.cs b/src/delivery/Kontent.Ai.Delivery/Serialization/Converters/ContentElementConverter.cs index 86692547f..6ae0cf960 100644 --- a/src/delivery/Kontent.Ai.Delivery/Serialization/Converters/ContentElementConverter.cs +++ b/src/delivery/Kontent.Ai.Delivery/Serialization/Converters/ContentElementConverter.cs @@ -34,8 +34,34 @@ public override ContentElement Read(ref Utf8JsonReader reader, Type typeToConver }; } + /// + /// Writing the runtime type is what lets a cached content type survive a round trip through a + /// distributed cache: every element carries the wire's own "type" field, so routes + /// the payload back to the same class without a synthetic discriminator. Serializing by the declared + /// type instead would drop and + /// silently. + /// public override void Write(Utf8JsonWriter writer, ContentElement value, JsonSerializerOptions options) - => throw new NotSupportedException("Serialization of ContentElement is not supported."); + { + // Derived types are written by their own contract - this converter matches ContentElement exactly, + // so handing it the base type back would re-enter here forever. + if (value.GetType() == typeof(ContentElement)) + { + WriteBaseElement(writer, value); + return; + } + + JsonSerializer.Serialize(writer, value, value.GetType(), options); + } + + private static void WriteBaseElement(Utf8JsonWriter writer, ContentElement value) + { + writer.WriteStartObject(); + writer.WriteString("type", value.Type); + writer.WriteString("name", value.Name); + writer.WriteString("codename", value.Codename); + writer.WriteEndObject(); + } private static ContentElement DeserializeBaseElement(JsonElement root) { diff --git a/src/delivery/Kontent.Ai.Delivery/Serialization/Converters/ContentElementDictionaryConverter.cs b/src/delivery/Kontent.Ai.Delivery/Serialization/Converters/ContentElementDictionaryConverter.cs index 3efe3c848..6582c8d2b 100644 --- a/src/delivery/Kontent.Ai.Delivery/Serialization/Converters/ContentElementDictionaryConverter.cs +++ b/src/delivery/Kontent.Ai.Delivery/Serialization/Converters/ContentElementDictionaryConverter.cs @@ -51,5 +51,17 @@ public override void Write( Utf8JsonWriter writer, IReadOnlyDictionary value, JsonSerializerOptions options) - => throw new NotSupportedException("Serialization of ContentElement dictionary is not supported."); + { + writer.WriteStartObject(); + + foreach (var (codename, element) in value) + { + writer.WritePropertyName(codename); + // Static type ContentElement, so this routes through ContentElementConverter and keeps the + // element's runtime shape - the key still wins on the way back in. + JsonSerializer.Serialize(writer, element, options); + } + + writer.WriteEndObject(); + } } diff --git a/src/delivery/Kontent.Ai.Urls.Tests/ApiApproval/PublicApiApprovalTests.PublicApi_ShouldNotChangeUnexpectedly.verified.txt b/src/delivery/Kontent.Ai.Urls.Tests/ApiApproval/PublicApiApprovalTests.PublicApi_ShouldNotChangeUnexpectedly.verified.txt index 096a98214..d1310005b 100644 --- a/src/delivery/Kontent.Ai.Urls.Tests/ApiApproval/PublicApiApprovalTests.PublicApi_ShouldNotChangeUnexpectedly.verified.txt +++ b/src/delivery/Kontent.Ai.Urls.Tests/ApiApproval/PublicApiApprovalTests.PublicApi_ShouldNotChangeUnexpectedly.verified.txt @@ -1,22 +1,22 @@ // Kontent.Ai.Urls.ImageTransformation public enum ImageCompression - Lossless - Lossy + Lossless = 0 + Lossy = 1 // Kontent.Ai.Urls.ImageTransformation public enum ImageFitMode - Clip - Crop - Scale + Clip = 0 + Crop = 2 + Scale = 1 // Kontent.Ai.Urls.ImageTransformation public enum ImageFormat - Gif - Jpg - Pjpg - Png - Png8 - Webp + Gif = 0 + Jpg = 3 + Pjpg = 4 + Png = 1 + Png8 = 2 + Webp = 5 // Kontent.Ai.Urls.ImageTransformation public sealed class ImageUrlBuilder diff --git a/src/delivery/Kontent.Ai.Urls.Tests/ImageUrlBuilderTests.cs b/src/delivery/Kontent.Ai.Urls.Tests/ImageUrlBuilderTests.cs index 6ca71f7a0..069a8227b 100644 --- a/src/delivery/Kontent.Ai.Urls.Tests/ImageUrlBuilderTests.cs +++ b/src/delivery/Kontent.Ai.Urls.Tests/ImageUrlBuilderTests.cs @@ -253,4 +253,37 @@ public void ComplexTransformation_TransformedQuery() Assert.Equal(expectedQuery, builder.Url.Query); } + + // An asset URL that already carries a query is what a default rendition preset produces. Applying a + // relative reference with its own query replaces the base one wholesale, so the rendition silently + // disappeared the moment any transformation was added. + [Fact] + public void Url_AssetUrlWithExistingQuery_KeepsItAlongsideTheTransformation() + { + var builder = new ImageUrlBuilder("https://assets.kontent.ai/env/id/photo.jpg?w=800&fm=webp"); + + var url = builder.WithHeight(200).Url.ToString(); + + Assert.Contains("fm=webp", url); + Assert.Contains("h=200", url); + } + + [Fact] + public void Url_TransformationSharingAKeyWithTheAssetUrl_Wins() + { + var builder = new ImageUrlBuilder("https://assets.kontent.ai/env/id/photo.jpg?w=800"); + + var url = builder.WithWidth(200).Url.ToString(); + + Assert.Contains("w=200", url); + Assert.DoesNotContain("w=800", url); + } + + [Fact] + public void Url_NoTransformations_LeavesTheAssetUrlAsItWas() + { + var builder = new ImageUrlBuilder("https://assets.kontent.ai/env/id/photo.jpg?w=800&fm=webp"); + + Assert.Equal("https://assets.kontent.ai/env/id/photo.jpg?w=800&fm=webp", builder.Url.ToString()); + } } diff --git a/src/delivery/Kontent.Ai.Urls/ImageTransformation/ImageUrlBuilder.cs b/src/delivery/Kontent.Ai.Urls/ImageTransformation/ImageUrlBuilder.cs index 834a51809..f370773cc 100644 --- a/src/delivery/Kontent.Ai.Urls/ImageTransformation/ImageUrlBuilder.cs +++ b/src/delivery/Kontent.Ai.Urls/ImageTransformation/ImageUrlBuilder.cs @@ -16,7 +16,36 @@ public sealed class ImageUrlBuilder(Uri assetUrl) { private readonly Uri _assetUrl = assetUrl ?? throw new ArgumentNullException(nameof(assetUrl)); private readonly Dictionary _queryParameters = []; - private string Query => _queryParameters.Any() ? $"?{string.Join("&", _queryParameters.Select(x => $"{x.Key}={x.Value}"))}" : ""; + + /// + /// The asset URL's own query merged with the transformations, the latter winning on a shared key. + /// + /// + /// Merged rather than replaced because a relative reference carrying a query replaces the base URL's + /// query wholesale. An asset URL that already carries one - which is what a rendition preset produces - + /// therefore lost it the moment any transformation was applied. Values are moved across as they were + /// found, so nothing is decoded and re-encoded on the way. + /// + private string Query + { + get + { + var merged = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var pair in _assetUrl.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var separator = pair.IndexOf('='); + merged[separator < 0 ? pair : pair[..separator]] = separator < 0 ? StringValues.Empty : pair[(separator + 1)..]; + } + + foreach (var (key, value) in _queryParameters) + { + merged[key] = value; + } + + return merged.Count > 0 ? $"?{string.Join("&", merged.Select(x => $"{x.Key}={x.Value}"))}" : ""; + } + } /// /// Gets the instance with applied transformations. diff --git a/src/delivery/README.md b/src/delivery/README.md index 125a1a29f..349e5a019 100644 --- a/src/delivery/README.md +++ b/src/delivery/README.md @@ -1311,22 +1311,24 @@ var result = await client.GetItem
("my-article") The built-in cache registrations (`AddDeliveryMemoryCache` / `AddDeliveryHybridCache`) in the `Kontent.Ai.Delivery.Caching` package use [FusionCache](https://github.com/ZiggyCreatures/FusionCache) internally. `InvalidateAsync` now returns `Task` (`true` on success, `false` on failure) so callers can detect silent invalidation failures — existing fire-and-forget call sites continue to work without changes. > [!NOTE] -> **Hybrid caching and the in-memory tier:** FusionCache always has an in-memory tier in front of the distributed one. `AddDeliveryHybridCache` bypasses it unless a backplane is registered, because without one there is nothing to keep the instances in step. Either way, hybrid entries are stored as raw JSON — FusionCache [uses the same serialized format in both tiers](https://github.com/ZiggyCreatures/FusionCache/issues/321) — so a hit goes through rehydration. For most workloads that cost is negligible; if you need maximum read throughput and a single instance is enough, `AddDeliveryMemoryCache` keeps hydrated objects and skips rehydration entirely. +> **Hybrid caching and the in-memory tier:** FusionCache always has an in-memory tier in front of the distributed one. `AddDeliveryHybridCache` uses it, and a backplane is what keeps it in step across instances - without one, an invalidation reaches only the instance that performed it (see the note above). Either way, hybrid entries are stored as raw JSON — FusionCache [uses the same serialized format in both tiers](https://github.com/ZiggyCreatures/FusionCache/issues/321) — so a hit goes through rehydration. For most workloads that cost is negligible; if you need maximum read throughput and a single instance is enough, `AddDeliveryMemoryCache` keeps hydrated objects and skips rehydration entirely. -To tune the underlying FusionCache instance, use `ConfigureFusionCacheOptions`: +To tune the underlying FusionCache instance, use `ConfigureFusionCache`: ```csharp services.AddDeliveryMemoryCache("production", opts => { opts.DefaultExpiration = TimeSpan.FromMinutes(30); - opts.ConfigureFusionCacheOptions = fusionOpts => - { - var fco = (ZiggyCreatures.Caching.Fusion.FusionCacheOptions)fusionOpts; - fco.DefaultEntryOptions.EagerRefreshThreshold = 0.8f; - }; + opts.ConfigureFusionCache(fusion => fusion.DefaultEntryOptions.EagerRefreshThreshold = 0.8f); }); ``` +`ConfigureFusionCache` comes from `Kontent.Ai.Delivery.Caching`, which already references FusionCache, so +the options arrive typed. The `ConfigureFusionCacheOptions` property it sets is typed as `object` because it +is declared in `Kontent.Ai.Delivery.Abstractions`, a package that deliberately references nothing — assign it +directly only if you are configuring the cache from somewhere that cannot see this package, and cast to +`FusionCacheOptions` yourself. + If you implement a custom cache manager that stores raw payloads (typical for distributed caches), override the `StorageMode` property to return `CacheStorageMode.RawJson` so the SDK uses the raw JSON caching path. Register custom cache managers per client using keyed registration: @@ -1771,6 +1773,12 @@ services.AddDeliveryClient( }); ``` +The default pipeline bounds each attempt at 30 seconds and then retries, which can legitimately outlast +`HttpClient`'s own 100-second ceiling on the whole call - retries and backoff included - so that ceiling +is lifted while the default pipeline is the one installed. Set `EnableResilience = false`, or replace the +pipeline through `configureResilience`, and the ceiling applies again: nothing else would bound the +request. Raise it with `configureHttpClient`, which runs after the SDK's own configuration. + ## Important Considerations ### API Rate Limits diff --git a/src/delivery/docs/for-developers.md b/src/delivery/docs/for-developers.md index 71cd78942..65f2ded68 100644 --- a/src/delivery/docs/for-developers.md +++ b/src/delivery/docs/for-developers.md @@ -868,8 +868,9 @@ Timing: for `AddDeliveryClient`, the callback runs when `IOptions(); diff --git a/src/delivery/docs/upgrade-guide.md b/src/delivery/docs/upgrade-guide.md index 49e451e78..c4d4e7b3a 100644 --- a/src/delivery/docs/upgrade-guide.md +++ b/src/delivery/docs/upgrade-guide.md @@ -575,7 +575,7 @@ The legacy `DeliveryCacheOptions` (with `CacheType`, `StaleContentExpiration`, ` | `FailSafeThrottleDuration` | `30s` | Minimum delay between background refresh attempts while fail-safe is active. | | `JitterMaxDuration` | `0` | Random jitter added to expirations to spread load and prevent thundering-herd. | | `EagerRefreshThreshold` | `0` | Fraction of TTL (0–1). When set, FusionCache refreshes entries in the background once this fraction has elapsed. | -| `ConfigureFusionCacheOptions` | `null` | Escape hatch — receives the underlying `FusionCacheOptions` for advanced FusionCache features (backplane, background ops, etc.). | +| `ConfigureFusionCacheOptions` | `null` | Escape hatch for advanced FusionCache features (backplane, background ops, etc.). Set it through the typed `ConfigureFusionCache` extension in `Kontent.Ai.Delivery.Caching`; the property itself is `object` because Abstractions references no FusionCache types. | ```csharp services.AddDeliveryMemoryCache(opts => diff --git a/src/management/CHANGELOG.md b/src/management/CHANGELOG.md index a6c52dfb9..c66141778 100644 --- a/src/management/CHANGELOG.md +++ b/src/management/CHANGELOG.md @@ -6,6 +6,12 @@ Entries before the move to this monorepo were imported from the GitHub Releases ## Unreleased +## 9.0.0-rc.2 (2026-08-12) _(prerelease)_ + +### Breaking changes + +- **`Reference` moved from the asset-folder and taxonomy-group PATCH bases onto the operations that need it, where it is `required`.** Both bases declared a nullable `Reference`, so a `remove`, `rename`, `move` or `replace` operation could be constructed without the reference the API demands — the compiler was fine with it and the request failed at the server. Each operation now declares its own: `required` on the ones that target something (`AssetFolderRemovePatchModel`, `AssetFolderRenamePatchModel`, `TaxonomyGroupRemovePatchModel`, `TaxonomyGroupMovePatchModel`, `TaxonomyGroupReplacePatchModel`), and still optional on `addInto`, where it names the parent to add into and its absence means the root. This matches the collection patch models, which were already shaped this way. The wire format is unchanged; code that already set `Reference` on these operations still compiles, and code that did not now fails to compile instead of failing at the API. + ### Changed - **`EnvironmentId` is no longer required when you only call subscription endpoints.** Subscription-scoped endpoints resolve against `/v2/subscriptions/{id}` and never touch an environment, but validation demanded an `EnvironmentId` regardless — so a subscription admin listing projects had to invent an environment GUID the SDK would never use. Each scope's client is now built only when its identifier is configured, and `EnvironmentId` is validated for format only when supplied, exactly as `SubscriptionId` already was. @@ -20,6 +26,28 @@ Entries before the move to this monorepo were imported from the GitHub Releases ### Fixed +- **The doc samples assert success rather than that a result object exists.** Forty of them ended in `Assert.NotNull(response)` against an `IManagementResult`, which is never null — so a failed call passed. Replacing that with `EnsureSuccess()` immediately surfaced five sample fixtures that had drifted out of step with their models and could no longer deserialize; those are refreshed from the fixtures the domain tests use. + +- **The pass-through `CreateRefitSettings` wrapper is gone**, and the deliberate `ScheduleResponseModel` date divergence is now recorded so it is not "corrected" later. + +- **IntelliSense wording corrections.** The single-item custom-app operations described themselves in the plural, `UpdatePreviewConfigurationAsync` was documented as a "Modify" (this SDK's word for `PATCH`, which it is not) with a parameter described as project-scoped, and a subscription-user method read "Retrieve a user metadata". Two enum members had typos in their summaries. + +- **The unused `Microsoft.Extensions.Logging.Abstractions` reference is gone**, so it no longer lands in the published package as a dependency nobody needs. + +- **The doc samples for importing content check their results.** Every one of the nineteen discarded the `IManagementResult` it received, so a failed call passed the test and the published sample taught ignoring the result pattern the SDK is built around. They now use `EnsureSuccess()`, which is both a real assertion and the idiomatic sample code — and each sample is backed by a response fixture that actually deserializes, so the assertion has something to check rather than passing on an empty body. + +- **The client factory no longer relabels an exception that came from your own registration.** `Get(name)` caught `InvalidOperationException` and reported it as a missing client — but the registration runs during resolution, so a `configureHttpClient` that rejected its input came back as "No management client registered with name '…'", pointing at the wrong thing entirely. A genuinely missing registration still says so. + +- **A doc sample no longer reads a bare timestamp in the machine's time zone.** Three samples fed `DateTime.Parse` into a `DateTimeOffset` scheduling parameter, which is exactly the ambiguity the SDK's date convention exists to prevent — taught in code people copy. They now construct the offset explicitly, as the README sample already did. + +- **The README no longer offers a Refit-settings hook that was removed.** `ManagementClientBuilder` customizes the resilience pipeline; the Refit hook it also advertised is gone. + +- **The `X-KC-SOURCE` header keeps naming the integration that made the call.** Attribution matched the SDK assembly by full name, which carries the version — and nothing pins `AssemblyVersion`, so the reference an integration recorded when it was built stopped matching on the first SDK release after that. The header then went silently missing for every consumer who had not rebuilt. Matching is now by simple name. + +- **The interface says what happens when you call into a scope you did not configure.** Since `EnvironmentId` became optional for subscription-only clients, every environment operation throws `InvalidOperationException` when it is missing — the same guard the subscription operations already documented, but stated nowhere for the ~80 methods on the other side. `IManagementClient`'s own remarks now describe both scopes and the guard once, rather than repeating an `` tag on every method. + +- **The documented error-handling model matches what the SDK does.** The README, the upgrade guide and the `IManagementResult` / typed-variant IntelliSense all said network-level and serialization failures "still propagate as exceptions". They do not, and have not since the result pattern landed: a transport failure that never reached the server and a response whose body could not be read are both failed results, carrying the exception in `Error.Exception`. A consumer following the old text wrote a `catch` that never fires and skipped the `IsSuccess` check that would have caught the failure. The docs now state what actually throws — cancellation, argument and configuration validation, `EnsureSuccess()`, and a typed-variant projection onto a record that no longer matches the content type — and the behaviour is pinned by tests. + - **The README now says how to configure a subscription-scoped call.** It listed `SubscriptionId` in the options table and mentioned "an API key with subscription scope", but never said the Subscription API key is a different credential from the Management API key or where to get one. There is now a worked example and a pointer to `https://app.kontent.ai/subscription//api-keys`, which only a subscription admin can use. ## 9.0.0-rc.1 (2026-08-07) _(prerelease)_ @@ -288,7 +316,7 @@ First public beta of the **ground-up modernized Management SDK**, targeting the ### Highlights -- **Result pattern instead of exceptions.** Methods no longer throw `ManagementException` on `4xx`/`5xx`. Every call returns `IManagementResult` / `IManagementResult` — inspect `IsSuccess`, `Value`, `Error`, `StatusCode`, `RequestUrl`. Opt back into throwing with `EnsureSuccess()`, or use `TryGetValue(out var value)`. Branch on specific failures via the `ManagementErrorCodes` catalog. Only programmer errors, invalid configuration, and network/serialization failures still throw. +- **Result pattern instead of exceptions.** Methods no longer throw `ManagementException` on `4xx`/`5xx`. Every call returns `IManagementResult` / `IManagementResult` — inspect `IsSuccess`, `Value`, `Error`, `StatusCode`, `RequestUrl`. Opt back into throwing with `EnsureSuccess()`, or use `TryGetValue(out var value)`. Branch on specific failures via the `ManagementErrorCodes` catalog. Only cancellation, programmer errors and invalid configuration still throw — a transport failure or an unreadable response body is a failed result like any other. - **Three ways to create a client.** The `new ManagementClient(options)` constructor still works (now `IDisposable` / `IAsyncDisposable` — `await using` it). New: `services.AddManagementClient(...)` for DI (with keyed/named clients via `IManagementClientFactory`) and a fluent `ManagementClientBuilder` for non-DI customization. - **Materialized listings.** `List…Async` walks every continuation page, merges them, and returns the whole set in one result (all-or-nothing — a failed page short-circuits, never a silently truncated set). Large listings (content items, assets, items-with-variants) also expose a streaming `Enumerate…PagesAsync` that yields one page at a time and lets you stop early. - **Immutable, strongly-typed models.** Generated models are records; an element property *is* its value (`string Title`, `decimal? Price`, `IEnumerable` for linked items) or a small companion record — `RichTextValue`, `DateTimeValue`, `UrlSlugValue`, `CustomValue` — each with an implicit conversion for the common case. Edit with a `with` expression. Date/time properties take a `DateTimeOffset` (stored as a UTC instant). diff --git a/src/management/CLAUDE.md b/src/management/CLAUDE.md index 2fbded9f1..61721fe69 100644 --- a/src/management/CLAUDE.md +++ b/src/management/CLAUDE.md @@ -43,6 +43,7 @@ Infrastructure the SDKs would otherwise each copy lives in `src/common`, compile - Sealed, immutable `record`s; `required` for what the API always returns/demands; nullability mirrors the wire contract exactly ("encode API learnings in the type system, not in prose"). - Explicit `[JsonPropertyName]` on **every** property — the serializer options deliberately have no naming policy. +- **One documented exception to the date rule**: `ScheduleResponseModel` exposes its timestamps as `DateTimeOffset` even though the server sends them. The API returns them alongside a separate `display_timezone`, and the pair is what a caller reschedules with, so the offset is carried rather than discarded. Test-pinned; do not "align" it with the rule below. - **Dates split by direction, and this is settled** — see the root `CLAUDE.md`. What the server sends (`LastModified`) is `DateTime`; what the caller supplies (`ScheduledTo`, `DateTimeValue`, `DueDate`) is `DateTimeOffset`. Do not "unify" them. - **All collection properties are `IReadOnlyList`** (never `IEnumerable`, `ISet`, or concrete types). Method *parameters* may accept `IEnumerable`. - Names mirror Kontent.ai API terminology; request and response shapes are separate records when the wire shapes differ (a response model with a fake-`required` field forced into a request body is a defect — see `UserRolesUpdateModel`). diff --git a/src/management/Kontent.Ai.Management.Tests/ApiApproval/PublicApiApprovalTests.ManagementPublicApi_ShouldNotChangeUnexpectedly.verified.txt b/src/management/Kontent.Ai.Management.Tests/ApiApproval/PublicApiApprovalTests.ManagementPublicApi_ShouldNotChangeUnexpectedly.verified.txt index 9d94fe781..0eff6a79c 100644 --- a/src/management/Kontent.Ai.Management.Tests/ApiApproval/PublicApiApprovalTests.ManagementPublicApi_ShouldNotChangeUnexpectedly.verified.txt +++ b/src/management/Kontent.Ai.Management.Tests/ApiApproval/PublicApiApprovalTests.ManagementPublicApi_ShouldNotChangeUnexpectedly.verified.txt @@ -257,7 +257,7 @@ public sealed class ManagementClient : IAsyncDisposable, IDisposable, IManagemen Void Dispose() // Kontent.Ai.Management -public sealed class ManagementErrorCodes +public static class ManagementErrorCodes const Int32 ArchivedVariantCannotBeUpdated = 223 const Int32 DuplicateAssetExternalId = 208 const Int32 DuplicateAssetRenditionExternalId = 234 @@ -281,8 +281,8 @@ public sealed class ManagementException : Exception String? RequestUrl { get; } // Kontent.Ai.Management -public sealed class ManagementResultExtensions - static Boolean TryGetValue(IManagementResult result, T&? value) +public static class ManagementResultExtensions + static Boolean TryGetValue(IManagementResult result, out T? value) static IManagementResult EnsureSuccess(IManagementResult result) static IManagementResult AsFailure(IManagementResult result) static T EnsureSuccess(IManagementResult result) @@ -350,7 +350,7 @@ public sealed class ManagementOptions : IValidatableObject IEnumerable Validate(ValidationContext validationContext) // Kontent.Ai.Management.Extensions -public sealed class AssetExtensions +public static class AssetExtensions static AssetFolderHierarchy? GetFolderHierarchyByCodename(IEnumerable folders, String codename) static AssetFolderHierarchy? GetFolderHierarchyByExternalId(IEnumerable folders, String externalId) static AssetFolderHierarchy? GetFolderHierarchyById(IEnumerable folders, Guid folderId) @@ -361,15 +361,15 @@ public sealed class AssetExtensions static String GetFullFolderPath(AssetFolderLinkingHierarchy folder) // Kontent.Ai.Management.Extensions -public sealed class ContentModelExtensions +public static class ContentModelExtensions static Task> ExportContentModelAsync(IManagementClient client, CancellationToken cancellationToken) // Kontent.Ai.Management.Extensions -public sealed class ElementMetadataExtensions +public static class ElementMetadataExtensions static T ToElement(ElementMetadataBase source) // Kontent.Ai.Management.Extensions -public sealed class ManagementClientExtensions +public static class ManagementClientExtensions static Task> CreateAssetAsync(IManagementClient client, FileContentSource fileContent, Func createModel, CancellationToken cancellationToken) static Task> UpsertAssetAsync(IManagementClient client, Reference identifier, FileContentSource fileContent, AssetUpsertModel upsertModel, CancellationToken cancellationToken) static Task> UpsertContentItemAsync(IManagementClient client, Reference identifier, ContentItemModel contentItem, CancellationToken cancellationToken) @@ -378,7 +378,7 @@ public sealed class ManagementClientExtensions static Task> UpsertLanguageVariantAsync(IManagementClient client, LanguageVariantIdentifier identifier, LanguageVariantModel languageVariant, CancellationToken cancellationToken) // Kontent.Ai.Management.Extensions -public sealed class ModelReferenceExtensions +public static class ModelReferenceExtensions static Reference ToReference(AssetModel asset) static Reference ToReference(CollectionModel collection) static Reference ToReference(ContentItemModel item) @@ -391,7 +391,7 @@ public sealed class ModelReferenceExtensions static Reference ToReference(WorkflowModel workflow) // Kontent.Ai.Management.Extensions -public sealed class ServiceCollectionExtensions +public static class ServiceCollectionExtensions static IServiceCollection AddManagementClient(IServiceCollection services, Action configureOptions, Action? configureHttpClient, Action>? configureResilience) static IServiceCollection AddManagementClient(IServiceCollection services, Action configureOptions) static IServiceCollection AddManagementClient(IServiceCollection services, Action configureOptions, Action? configureHttpClient, Action>? configureResilience) @@ -404,7 +404,7 @@ public sealed class ServiceCollectionExtensions static IServiceCollection AddManagementClient(IServiceCollection services, String name, IConfigurationSection configurationSection, Action? configureHttpClient, Action>? configureResilience) // Kontent.Ai.Management.Extensions -public sealed class VariantIdentifierExtensions +public static class VariantIdentifierExtensions static LanguageVariantIdentifier ToIdentifier(ItemWithVariantFilterResultModel model) static LanguageVariantIdentifier ToIdentifier(LanguageVariantMetadata variant) static VariantIdentifierModel ToVariantIdentifier(ItemWithVariantFilterResultModel model) @@ -459,6 +459,7 @@ public sealed class AssetFolderAddIntoPatchModel : AssetFolderOperationBaseModel .ctor() Reference? After { get; init; } Reference? Before { get; init; } + Reference? Reference { get; init; } String Op { get; } required AssetFolderHierarchy Value { get; init; } AssetFolderAddIntoPatchModel $() @@ -469,8 +470,7 @@ public sealed class AssetFolderAddIntoPatchModel : AssetFolderOperationBaseModel String ToString() // Kontent.Ai.Management.Models.AssetFolders.Patch -public class AssetFolderOperationBaseModel : IEquatable - Reference? Reference { get; init; } +public abstract class AssetFolderOperationBaseModel : IEquatable String Op { get; } AssetFolderOperationBaseModel $() Boolean Equals(AssetFolderOperationBaseModel? other) @@ -482,6 +482,7 @@ public class AssetFolderOperationBaseModel : IEquatable .ctor() String Op { get; } + required Reference Reference { get; init; } AssetFolderRemovePatchModel $() Boolean Equals(AssetFolderOperationBaseModel? other) Boolean Equals(AssetFolderRemovePatchModel? other) @@ -493,6 +494,7 @@ public sealed class AssetFolderRemovePatchModel : AssetFolderOperationBaseModel, public sealed class AssetFolderRenamePatchModel : AssetFolderOperationBaseModel, IEquatable .ctor() String Op { get; } + required Reference Reference { get; init; } required String Value { get; init; } AssetFolderRenamePatchModel $() Boolean Equals(AssetFolderOperationBaseModel? other) @@ -549,7 +551,7 @@ public sealed class AssetRenditionUpdateModel : IEquatable +public abstract class ImageTransformation : IEquatable ImageTransformationMode Mode { get; } Boolean Equals(ImageTransformation? other) Boolean Equals(Object? obj) @@ -559,11 +561,11 @@ public class ImageTransformation : IEquatable // Kontent.Ai.Management.Models.AssetRenditions public enum ImageTransformationFit - Clip + Clip = 0 // Kontent.Ai.Management.Models.AssetRenditions public enum ImageTransformationMode - Rect + Rect = 0 // Kontent.Ai.Management.Models.AssetRenditions public sealed class RectangleResizeTransformation : ImageTransformation, IEquatable @@ -695,7 +697,7 @@ public sealed class FileReference : IEquatable // Kontent.Ai.Management.Models.Assets public enum FileReferenceType - Internal + Internal = 0 // Kontent.Ai.Management.Models.Collections public sealed class CollectionCreateModel : IEquatable @@ -762,7 +764,7 @@ public sealed class CollectionMovePatchModel : CollectionOperationBaseModel, IEq String ToString() // Kontent.Ai.Management.Models.Collections.Patch -public class CollectionOperationBaseModel : IEquatable +public abstract class CollectionOperationBaseModel : IEquatable String Op { get; } Boolean Equals(CollectionOperationBaseModel? other) Boolean Equals(Object? obj) @@ -772,7 +774,7 @@ public class CollectionOperationBaseModel : IEquatable @@ -879,8 +881,8 @@ public sealed class RichTextValue : IEquatable // Kontent.Ai.Management.Models.Content public enum UrlSlugMode - Autogenerated - Custom + Autogenerated = 0 + Custom = 1 // Kontent.Ai.Management.Models.Content public sealed class UrlSlugValue : IEquatable @@ -937,7 +939,7 @@ public sealed class ContentModelMovePatchModel : ContentModelOperationBaseModel, String ToString() // Kontent.Ai.Management.Models.ContentModel.Patch -public class ContentModelOperationBaseModel : IEquatable +public abstract class ContentModelOperationBaseModel : IEquatable String Op { get; } required String Path { get; init; } Boolean Equals(ContentModelOperationBaseModel? other) @@ -947,7 +949,7 @@ public class ContentModelOperationBaseModel : IEquatable // Kontent.Ai.Management.Models.CustomApps public enum CustomAppDisplayMode - Dialog - FullScreen + Dialog = 1 + FullScreen = 0 // Kontent.Ai.Management.Models.CustomApps public sealed class CustomAppModel : IEquatable @@ -1060,7 +1062,7 @@ public sealed class CustomAppAddIntoPatchModel : CustomAppOperationBaseModel, IE String ToString() // Kontent.Ai.Management.Models.CustomApps.Patch -public class CustomAppOperationBaseModel : IEquatable +public abstract class CustomAppOperationBaseModel : IEquatable String Op { get; } required CustomAppPropertyName PropertyName { get; init; } required Object? Value { get; init; } @@ -1071,7 +1073,7 @@ public class CustomAppOperationBaseModel : IEquatable @@ -1148,7 +1150,7 @@ public sealed class VariantIssue : IEquatable VariantIssue $() // Kontent.Ai.Management.Models.EnvironmentValidation -public class AsyncValidationTaskIssueModel : IEquatable +public abstract class AsyncValidationTaskIssueModel : IEquatable required AsyncValidationTaskIssueType IssueType { get; init; } required IReadOnlyList Issues { get; init; } AsyncValidationTaskIssueModel $() @@ -1159,8 +1161,8 @@ public class AsyncValidationTaskIssueModel : IEquatable @@ -1176,15 +1178,15 @@ public sealed class AsyncValidationTaskModel : IEquatable @@ -1211,9 +1213,9 @@ public sealed class AsyncValidationTaskVariantIssueModel : AsyncValidationTaskIs // Kontent.Ai.Management.Models.Environments public enum CloningState - Done - Failed - InProgress + Done = 2 + Failed = 1 + InProgress = 0 // Kontent.Ai.Management.Models.Environments public sealed class CopyDataOptions : IEquatable @@ -1296,7 +1298,7 @@ public sealed class MarkAsProductionModel : IEquatable String ToString() // Kontent.Ai.Management.Models.Environments.Patch -public class EnvironmentOperationBaseModel : IEquatable +public abstract class EnvironmentOperationBaseModel : IEquatable String Op { get; } Boolean Equals(EnvironmentOperationBaseModel? other) Boolean Equals(Object? obj) @@ -1467,7 +1469,7 @@ public sealed class LanguageVariantIdentifier : IEquatable +public abstract class LanguageVariantMetadata : IEquatable String? Note { get; init; } required DateTime LastModified { get; init; } required DueDateModel DueDate { get; init; } @@ -1530,7 +1532,7 @@ public sealed class AssetElement : BaseElement, IEquatable String ToString() // Kontent.Ai.Management.Models.LanguageVariants.Elements -public class BaseElement : IEquatable +public abstract class BaseElement : IEquatable required Reference Element { get; init; } BaseElement $() Boolean Equals(BaseElement? other) @@ -1695,7 +1697,7 @@ public sealed class LanguageModel : IEquatable String ToString() // Kontent.Ai.Management.Models.Languages.Patch -public sealed class LanguagePatch +public static class LanguagePatch static LanguagePatchModel Codename(String codename) static LanguagePatchModel FallbackLanguage(Reference language) static LanguagePatchModel IsActive(Boolean isActive) @@ -1715,10 +1717,10 @@ public sealed class LanguagePatchModel : IEquatable // Kontent.Ai.Management.Models.Languages.Patch public enum LanguagePropertyName - Codename - FallbackLanguage - IsActive - Name + Codename = 0 + FallbackLanguage = 2 + IsActive = 3 + Name = 1 // Kontent.Ai.Management.Models.PreviewConfiguration public sealed class PreviewConfigurationModel : IEquatable @@ -1902,7 +1904,7 @@ public sealed class SpaceModel : IEquatable String ToString() // Kontent.Ai.Management.Models.Spaces.Patch -public sealed class SpacePatch +public static class SpacePatch static SpaceReplacePatchModel Codename(String codename) static SpaceReplacePatchModel Collections(Reference[] collections) static SpaceReplacePatchModel Name(String name) @@ -1910,10 +1912,10 @@ public sealed class SpacePatch // Kontent.Ai.Management.Models.Spaces.Patch public enum SpacePropertyName - Codename - Collections - Name - RootItem + Codename = 0 + Collections = 3 + Name = 1 + RootItem = 2 // Kontent.Ai.Management.Models.Spaces.Patch public sealed class SpaceReplacePatchModel : IEquatable @@ -2031,7 +2033,7 @@ public sealed class SubscriptionUserRoleModel : IEquatable$() // Kontent.Ai.Management.Models.TaxonomyGroups -public class TaxonomyBaseModel : IEquatable +public abstract class TaxonomyBaseModel : IEquatable String? Codename { get; init; } String? ExternalId { get; init; } required String Name { get; init; } @@ -2093,6 +2095,7 @@ public sealed class TaxonomyGroupAddIntoPatchModel : TaxonomyGroupOperationBaseM .ctor() Reference? After { get; init; } Reference? Before { get; init; } + Reference? Reference { get; init; } String Op { get; } required TaxonomyTermCreateModel Value { get; init; } Boolean Equals(Object? obj) @@ -2109,6 +2112,7 @@ public sealed class TaxonomyGroupMovePatchModel : TaxonomyGroupOperationBaseMode Reference? Before { get; init; } Reference? Under { get; init; } String Op { get; } + required Reference Reference { get; init; } Boolean Equals(Object? obj) Boolean Equals(TaxonomyGroupMovePatchModel? other) Boolean Equals(TaxonomyGroupOperationBaseModel? other) @@ -2117,8 +2121,7 @@ public sealed class TaxonomyGroupMovePatchModel : TaxonomyGroupOperationBaseMode TaxonomyGroupMovePatchModel $() // Kontent.Ai.Management.Models.TaxonomyGroups.Patch -public class TaxonomyGroupOperationBaseModel : IEquatable - Reference? Reference { get; init; } +public abstract class TaxonomyGroupOperationBaseModel : IEquatable String Op { get; } Boolean Equals(Object? obj) Boolean Equals(TaxonomyGroupOperationBaseModel? other) @@ -2127,21 +2130,22 @@ public class TaxonomyGroupOperationBaseModel : IEquatable$() // Kontent.Ai.Management.Models.TaxonomyGroups.Patch -public sealed class TaxonomyGroupPatch +public static class TaxonomyGroupPatch static TaxonomyGroupReplacePatchModel ReplaceCodename(Reference target, String codename) static TaxonomyGroupReplacePatchModel ReplaceName(Reference target, String name) static TaxonomyGroupReplacePatchModel ReplaceTerms(Reference target, TaxonomyTermCreateModel[] terms) // Kontent.Ai.Management.Models.TaxonomyGroups.Patch public enum TaxonomyGroupPropertyName - Codename - Name - Terms + Codename = 0 + Name = 1 + Terms = 2 // Kontent.Ai.Management.Models.TaxonomyGroups.Patch public sealed class TaxonomyGroupRemovePatchModel : TaxonomyGroupOperationBaseModel, IEquatable .ctor() String Op { get; } + required Reference Reference { get; init; } Boolean Equals(Object? obj) Boolean Equals(TaxonomyGroupOperationBaseModel? other) Boolean Equals(TaxonomyGroupRemovePatchModel? other) @@ -2154,6 +2158,7 @@ public sealed class TaxonomyGroupReplacePatchModel : TaxonomyGroupOperationBaseM .ctor() String Op { get; } required Object Value { get; init; } + required Reference Reference { get; init; } required TaxonomyGroupPropertyName PropertyName { get; init; } Boolean Equals(Object? obj) Boolean Equals(TaxonomyGroupOperationBaseModel? other) @@ -2249,9 +2254,9 @@ public sealed class LimitModel : IEquatable // Kontent.Ai.Management.Models.Types public enum LimitType - AtLeast - AtMost - Exactly + AtLeast = 0 + AtMost = 2 + Exactly = 1 // Kontent.Ai.Management.Models.Types.Elements public sealed class AssetElementMetadataModel : ContentElementMetadataBase, IEquatable @@ -2272,7 +2277,7 @@ public sealed class AssetElementMetadataModel : ContentElementMetadataBase, IEqu String ToString() // Kontent.Ai.Management.Models.Types.Elements -public class ContentElementMetadataBase : ElementMetadataBase, IEquatable +public abstract class ContentElementMetadataBase : ElementMetadataBase, IEquatable Boolean IsNonLocalizable { get; init; } Boolean IsRequired { get; init; } String? Guidelines { get; init; } @@ -2324,7 +2329,7 @@ public sealed class DateTimeElementMetadataModel : ContentElementMetadataBase, I String ToString() // Kontent.Ai.Management.Models.Types.Elements -public class ElementMetadataBase : IEquatable +public abstract class ElementMetadataBase : IEquatable ElementMetadataType Type { get; } Guid? Id { get; init; } Reference? ContentGroup { get; init; } @@ -2338,24 +2343,24 @@ public class ElementMetadataBase : IEquatable // Kontent.Ai.Management.Models.Types.Elements public enum ElementMetadataType - Asset - ContentTypeSnippet - Custom - DateTime - Guidelines - LinkedItems - MultipleChoice - Number - RichText - Subpages - Taxonomy - Text - UrlSlug + Asset = 6 + ContentTypeSnippet = 11 + Custom = 12 + DateTime = 5 + Guidelines = 8 + LinkedItems = 7 + MultipleChoice = 4 + Number = 3 + RichText = 2 + Subpages = 13 + Taxonomy = 9 + Text = 1 + UrlSlug = 10 // Kontent.Ai.Management.Models.Types.Elements public enum FileType - Adjustable - Any + Adjustable = 1 + Any = 0 // Kontent.Ai.Management.Models.Types.Elements public sealed class GuidelinesElementMetadataModel : ElementMetadataBase, IEquatable @@ -2412,8 +2417,8 @@ public sealed class MultipleChoiceElementMetadataModel : ContentElementMetadataB // Kontent.Ai.Management.Models.Types.Elements public enum MultipleChoiceMode - Multiple - Single + Multiple = 0 + Single = 1 // Kontent.Ai.Management.Models.Types.Elements public sealed class MultipleChoiceOptionModel : IEquatable @@ -2443,10 +2448,10 @@ public sealed class NumberElementMetadataModel : ContentElementMetadataBase, IEq // Kontent.Ai.Management.Models.Types.Elements public enum RichTextBlockType - ComponentsAndItems - Images - Tables - Text + ComponentsAndItems = 3 + Images = 2 + Tables = 1 + Text = 0 // Kontent.Ai.Management.Models.Types.Elements public sealed class RichTextElementMetadataModel : ContentElementMetadataBase, IEquatable @@ -2475,30 +2480,30 @@ public sealed class RichTextElementMetadataModel : ContentElementMetadataBase, I // Kontent.Ai.Management.Models.Types.Elements public enum RichTextFormattingType - Bold - Code - Italic - Link - Subscript - Superscript - Unstyled + Bold = 0 + Code = 1 + Italic = 2 + Link = 3 + Subscript = 4 + Superscript = 5 + Unstyled = 6 // Kontent.Ai.Management.Models.Types.Elements public enum RichTextTableBlockType - Images - Text + Images = 1 + Text = 0 // Kontent.Ai.Management.Models.Types.Elements public enum RichTextTextBlockType - HeadingFive - HeadingFour - HeadingOne - HeadingSix - HeadingThree - HeadingTwo - OrderedList - Paragraph - UnorderedList + HeadingFive = 7 + HeadingFour = 6 + HeadingOne = 3 + HeadingSix = 8 + HeadingThree = 5 + HeadingTwo = 4 + OrderedList = 0 + Paragraph = 2 + UnorderedList = 1 // Kontent.Ai.Management.Models.Types.Elements public sealed class SubpagesElementMetadataModel : ContentElementMetadataBase, IEquatable @@ -2546,8 +2551,8 @@ public sealed class TextElementMetadataModel : ContentElementMetadataBase, IEqua // Kontent.Ai.Management.Models.Types.Elements public enum TextLengthLimitType - Characters - Words + Characters = 1 + Words = 0 // Kontent.Ai.Management.Models.Types.Elements public sealed class UrlSlugDependency : IEquatable @@ -2620,7 +2625,7 @@ public sealed class ElementDefaultValueEnvelope`1 : IEquatable : IEquatable> +public abstract class ElementDefaultValue`1 : IEquatable> required ElementDefaultValueEnvelope Global { get; init; } Boolean Equals(ElementDefaultValue? other) Boolean Equals(Object? obj) @@ -2747,10 +2752,10 @@ public sealed class UserRolesUpdateModel : IEquatable // Kontent.Ai.Management.Models.VariantFilter public enum VariantFilterCompletionStatus - AllDone - NotTranslated - Ready - Unfinished + AllDone = 3 + NotTranslated = 2 + Ready = 1 + Unfinished = 0 // Kontent.Ai.Management.Models.VariantFilter public sealed class VariantFilterFiltersModel : IEquatable @@ -2775,14 +2780,14 @@ public sealed class VariantFilterFiltersModel : IEquatable @@ -2797,9 +2802,9 @@ public sealed class VariantFilterOrderModel : IEquatable @@ -2852,10 +2857,10 @@ public sealed class WebhookCreateModel : IEquatable // Kontent.Ai.Management.Models.Webhooks public enum WebhookHealthStatus - Dead - Failing - Unknown - Working + Dead = 3 + Failing = 2 + Unknown = 0 + Working = 1 // Kontent.Ai.Management.Models.Webhooks public sealed class WebhookModel : IEquatable @@ -2877,8 +2882,8 @@ public sealed class WebhookModel : IEquatable // Kontent.Ai.Management.Models.Webhooks.Triggers public enum DeliverySlot - Preview - Published + Preview = 1 + Published = 0 // Kontent.Ai.Management.Models.Webhooks.Triggers public sealed class DeliveryTriggersModel : IEquatable @@ -2898,15 +2903,15 @@ public sealed class DeliveryTriggersModel : IEquatable // Kontent.Ai.Management.Models.Webhooks.Triggers public enum WebhookEvents - All - Specific + All = 0 + Specific = 1 // Kontent.Ai.Management.Models.Webhooks.Triggers.Asset public enum AssetAction - Changed - Created - Deleted - MetadataChanged + Changed = 1 + Created = 0 + Deleted = 2 + MetadataChanged = 3 // Kontent.Ai.Management.Models.Webhooks.Triggers.Asset public sealed class AssetActionModel : IEquatable @@ -2931,13 +2936,13 @@ public sealed class AssetTriggerModel : IEquatable // Kontent.Ai.Management.Models.Webhooks.Triggers.ContentItem public enum ContentItemAction - Changed - Created - Deleted - MetadataChanged - Published - Unpublished - WorkflowStepChanged + Changed = 1 + Created = 0 + Deleted = 2 + MetadataChanged = 6 + Published = 3 + Unpublished = 4 + WorkflowStepChanged = 5 // Kontent.Ai.Management.Models.Webhooks.Triggers.ContentItem public sealed class ContentItemActionModel : IEquatable @@ -2987,9 +2992,9 @@ public sealed class ContentItemWorkflowTransition : IEquatable @@ -3025,9 +3030,9 @@ public sealed class ContentTypeTriggerModel : IEquatable @@ -3063,13 +3068,13 @@ public sealed class LanguageTriggerModel : IEquatable // Kontent.Ai.Management.Models.Webhooks.Triggers.Taxonomy public enum TaxonomyAction - Created - Deleted - MetadataChanged - TermChanged - TermCreated - TermDeleted - TermsMoved + Created = 0 + Deleted = 2 + MetadataChanged = 1 + TermChanged = 4 + TermCreated = 3 + TermDeleted = 5 + TermsMoved = 6 // Kontent.Ai.Management.Models.Webhooks.Triggers.Taxonomy public sealed class TaxonomyActionModel : IEquatable @@ -3204,22 +3209,22 @@ public sealed class WorkflowScopeUpsertModel : IEquatable diff --git a/src/management/Kontent.Ai.Management.Tests/CodeSamples/CmApiV2.cs b/src/management/Kontent.Ai.Management.Tests/CodeSamples/CmApiV2.cs index 029f85dc7..17a3b6919 100644 --- a/src/management/Kontent.Ai.Management.Tests/CodeSamples/CmApiV2.cs +++ b/src/management/Kontent.Ai.Management.Tests/CodeSamples/CmApiV2.cs @@ -31,7 +31,6 @@ using Kontent.Ai.Management.Models.Webhooks.Triggers.Taxonomy; using Kontent.Ai.Management.Models.Workflow; using Kontent.Ai.Management.Tests.Base; -using System.Globalization; namespace Kontent.Ai.Management.Tests.CodeSamples; @@ -41,461 +40,459 @@ namespace Kontent.Ai.Management.Tests.CodeSamples; public class CmApiV2 { - // IF YOU MAKE ANY CHANGE TO THIS FILE - ADJUST THE CODE SAMPLES - // USE FOLLOWING TEMPLATE - - // DocSection: cm_api_v2_delete_asset - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net - // using Kontent.Ai.Management; + // IF YOU MAKE ANY CHANGE TO THIS FILE - ADJUST THE CODE SAMPLES AT + // https://github.com/Kontent-ai-Learn/kontent-ai-learn-code-samples/tree/master/net/management-api-v2 // - // var client = new ManagementClient(new ManagementOptions - // { - // ApiKey = "", - // EnvironmentId = "" - // }); - // - // var identifier = Reference.ById(Guid.Parse("fcbb12e6-66a3-4672-85d9-d502d16b8d9c")); - // // var identifier = Reference.ByExternalId("which-brewing-fits-you"); - - // await client.DeleteAssetAsync(identifier); - // EndDocSection + // A section is published verbatim, so it must hold the sample and nothing else: it opens below the + // mock client and closes above the assertions, both of which are test scaffolding. The id on the + // opening marker is the join key with the file of the same name in that repository - do not rename + // it casually. private const string SampleFolder = "CodeSamples"; - // DocSection: cm_api_v2_delete_asset - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task DeleteAsset() { var client = MockClientFactory.CreateForSample(SampleFolder); + // DocSection: cm_api_v2_delete_asset + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = Reference.ById(Guid.Parse("fcbb12e6-66a3-4672-85d9-d502d16b8d9c")); // var identifier = Reference.ByExternalId("which-brewing-fits-you"); await client.DeleteAssetAsync(identifier); + // EndDocSection } - // DocSection: cm_api_v2_delete_item - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task DeleteItem() { var client = MockClientFactory.CreateForSample(SampleFolder); + // DocSection: cm_api_v2_delete_item + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = Reference.ById(Guid.Parse("f4b3fc05-e988-4dae-9ac1-a94aba566474")); // var identifier = Reference.ByCodename("my_article"); // var identifier = Reference.ByExternalId("59713"); await client.DeleteContentItemAsync(identifier); + // EndDocSection } - // DocSection: cm_api_v2_delete_snippet - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task DeleteSnippet() { var client = MockClientFactory.CreateForSample(SampleFolder); + // DocSection: cm_api_v2_delete_snippet + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = Reference.ById(Guid.Parse("baf884be-531f-441f-ae88-64205efdd0f6")); // var identifier = Reference.ByCodename("metadata"); // var identifier = Reference.ByExternalId("snippet-type-123"); await client.DeleteContentTypeSnippetAsync(identifier); + // EndDocSection } - // DocSection: cm_api_v2_delete_taxonomy_group - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task DeleteTaxonomyGroup() { var client = MockClientFactory.CreateForSample(SampleFolder); + // DocSection: cm_api_v2_delete_taxonomy_group + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = Reference.ById(Guid.Parse("0be13600-e57c-577d-8108-c8d860330985")); // var identifier = Reference.ByCodename("personas"); // var identifier = Reference.ByExternalId("Tax-Group-123"); await client.DeleteTaxonomyGroupAsync(identifier); + // EndDocSection } - // DocSection: cm_api_v2_delete_type - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task DeleteType() { var client = MockClientFactory.CreateForSample(SampleFolder); + // DocSection: cm_api_v2_delete_type + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = Reference.ById(Guid.Parse("269202ad-1d9d-47fd-b3e8-bdb05b3e3cf0")); // var identifier = Reference.ByCodename("hosted_video"); // var identifier = Reference.ByExternalId("Content-Type-123"); await client.DeleteContentTypeAsync(identifier); + // EndDocSection } - // DocSection: cm_api_v2_delete_variant - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task DeleteLanguageVariant() { var client = MockClientFactory.CreateForSample(SampleFolder); + // DocSection: cm_api_v2_delete_variant + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = LanguageVariantIdentifier.ByIds(Guid.Parse("f4b3fc05-e988-4dae-9ac1-a94aba566474"), Guid.Parse("d1f95fde-af02-b3b5-bd9e-f232311ccab8")); // var identifier = LanguageVariantIdentifier.ByCodenames("my_article", "es-ES"); await client.DeleteLanguageVariantAsync(identifier); + // EndDocSection } - // DocSection: cm_api_v2_delete_webhook - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task DeleteWebhook() { var client = MockClientFactory.CreateForSample(SampleFolder); + // DocSection: cm_api_v2_delete_webhook + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = Reference.ById(Guid.Parse("d53360f7-79e1-42f4-a524-1b53a417d03e")); await client.DeleteWebhookAsync(identifier); + // EndDocSection } - // DocSection: cm_api_v2_delete_workflow - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task DeleteWorkflow() { var client = MockClientFactory.CreateForSample(SampleFolder); + // DocSection: cm_api_v2_delete_workflow + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = Reference.ById(Guid.Parse("8bfdb62d-7aa1-473b-9d80-311ef93db108")); // var identifier = Reference.ByCodename("my_workflow"); await client.DeleteWorkflowAsync(identifier); + // EndDocSection } - // DocSection: cm_api_v2_delete_environment - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task DeleteEnvironment() { var client = MockClientFactory.CreateForSample(SampleFolder); + // DocSection: cm_api_v2_delete_environment + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net await client.DeleteEnvironmentAsync(); + // EndDocSection } - // DocSection: cm_api_v2_get_asset - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task GetAsset() { var client = MockClientFactory.CreateForSample(SampleFolder, "Asset.json"); + // DocSection: cm_api_v2_get_asset + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = Reference.ById(Guid.Parse("fcbb12e6-66a3-4672-85d9-d502d16b8d9c")); // var identifier = Reference.ByCodename("which-brewing-fits-you"); - var response = await client.GetAssetAsync(identifier); - - Assert.NotNull(response); + var response = (await client.GetAssetAsync(identifier)).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_get_assets - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task GetAssets() { var client = MockClientFactory.CreateForSample(SampleFolder, "Assets.json"); + // DocSection: cm_api_v2_get_assets + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net IReadOnlyList assets = (await client.ListAssetsAsync()).EnsureSuccess(); + // EndDocSection Assert.Single(assets); } - // DocSection: cm_api_v2_get_rendition - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task GetRendition() { var client = MockClientFactory.CreateForSample(SampleFolder, "AssetRendition.json"); + // DocSection: cm_api_v2_get_rendition + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = AssetRenditionIdentifier.ByIds(Guid.Parse("fcbb12e6-66a3-4672-85d9-d502d16b8d9c"), Guid.Parse("ce559491-0fc1-494b-96f3-244bc095de57")); // var identifier = new AssetRenditionIdentifier(Reference.ByExternalId("which-brewing-fits-you"), Reference.ByExternalId("hero-image-rendition")); - var response = await client.GetAssetRenditionAsync(identifier); - - Assert.NotNull(response); + var response = (await client.GetAssetRenditionAsync(identifier)).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_get_renditions - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task GetRenditions() { var client = MockClientFactory.CreateForSample(SampleFolder, "AssetRenditions.json"); + // DocSection: cm_api_v2_get_renditions + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var assetReference = Reference.ById(Guid.Parse("fcbb12e6-66a3-4672-85d9-d502d16b8d9c")); // var assetReference = Reference.ByExternalId("which-brewing-fits-you"); IReadOnlyList renditions = (await client.ListAssetRenditionsAsync(assetReference)).EnsureSuccess(); + // EndDocSection Assert.Single(renditions); } - // DocSection: cm_api_v2_get_components_of_type - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task GetComponentsOfType() { var client = MockClientFactory.CreateForSample(SampleFolder, "ContentItemsWithComponents.json"); + // DocSection: cm_api_v2_get_components_of_type + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = Reference.ById(Guid.Parse("6434e475-5a29-4866-9fd1-6d1ca873f5be")); // var identifier = Reference.ByCodename("article"); // var identifier = Reference.ByExternalId("my-article-id"); IReadOnlyList response = (await client.ListLanguageVariantsOfContentTypeWithComponentsAsync(identifier)).EnsureSuccess(); - - Assert.NotNull(response); + // EndDocSection } - // DocSection: cm_api_v2_get_content_collections - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task GetContentCollections() { var client = MockClientFactory.CreateForSample(SampleFolder, "Collections.json"); + // DocSection: cm_api_v2_get_content_collections + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var response = (await client.GetCollectionsAsync()).EnsureSuccess(); + // EndDocSection Assert.Equal(2, response.Collections.Count()); } - // DocSection: cm_api_v2_get_asset_folders - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task GetFolders() { var client = MockClientFactory.CreateForSample(SampleFolder, "AssetFolders.json"); + // DocSection: cm_api_v2_get_asset_folders + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var response = (await client.GetAssetFoldersAsync()).EnsureSuccess(); + // EndDocSection Assert.Equal(2, response.Folders.Count()); Assert.Single(response.Folders.First().Folders!); } - // DocSection: cm_api_v2_get_item - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task GetItem() { var client = MockClientFactory.CreateForSample(SampleFolder, "ContentItem.json"); + // DocSection: cm_api_v2_get_item + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = Reference.ById(Guid.Parse("f4b3fc05-e988-4dae-9ac1-a94aba566474")); // var identifier = Reference.ByCodename("my_article"); // var identifier = Reference.ByExternalId("59713"); - var response = await client.GetContentItemAsync(identifier); - - Assert.NotNull(response); + var response = (await client.GetContentItemAsync(identifier)).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_get_items - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task GetItems() { var client = MockClientFactory.CreateForSample(SampleFolder, "ContentItems.json"); + // DocSection: cm_api_v2_get_items + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net IReadOnlyList response = (await client.ListContentItemsAsync()).EnsureSuccess(); + // EndDocSection Assert.Single(response); } - // DocSection: cm_api_v2_get_language - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task GetLanguage() { var client = MockClientFactory.CreateForSample(SampleFolder, "Language.json"); + // DocSection: cm_api_v2_get_language + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = Reference.ById(Guid.Parse("2ea66788-d3b8-5ff5-b37e-258502e4fd5d")); // var identifier = Reference.ByCodename("de-DE"); // var identifier = Reference.ByExternalId("standard-german"); - var response = await client.GetLanguageAsync(identifier); - - Assert.NotNull(response); + var response = (await client.GetLanguageAsync(identifier)).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_get_languages - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task GetLanguages() { var client = MockClientFactory.CreateForSample(SampleFolder, "Languages.json"); + // DocSection: cm_api_v2_get_languages + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var count = (await client.ListLanguagesAsync()).EnsureSuccess().Count; + // EndDocSection Assert.Equal(1, count); } - // DocSection: cm_api_v2_get_project_information - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task GetProjectInformation() { var client = MockClientFactory.CreateForSample(SampleFolder, "Project.json"); - var response = await client.GetEnvironmentInformationAsync(); - - Assert.NotNull(response); + // DocSection: cm_api_v2_get_project_information + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + var response = (await client.GetEnvironmentInformationAsync()).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_get_snippet - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task GetSnippet() { var client = MockClientFactory.CreateForSample(SampleFolder, "Snippet.json"); + // DocSection: cm_api_v2_get_snippet + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = Reference.ById(Guid.Parse("baf884be-531f-441f-ae88-64205efdd0f6")); // var identifier = Reference.ByCodename("metadata"); // var identifier = Reference.ByExternalId("snippet-type-123"); - var response = await client.GetContentTypeSnippetAsync(identifier); - - Assert.NotNull(response); + var response = (await client.GetContentTypeSnippetAsync(identifier)).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_get_snippets - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task GetSnippets() { var client = MockClientFactory.CreateForSample(SampleFolder, "Snippets.json"); + // DocSection: cm_api_v2_get_snippets + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net IReadOnlyList response = (await client.ListContentTypeSnippetsAsync()).EnsureSuccess(); + // EndDocSection Assert.Single(response); } - // DocSection: cm_api_v2_get_taxonomy_group - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task GetTaxonomyGroup() { var client = MockClientFactory.CreateForSample(SampleFolder, "TaxonomyGroup.json"); + // DocSection: cm_api_v2_get_taxonomy_group + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = Reference.ById(Guid.Parse("0be13600-e57c-577d-8108-c8d860330985")); // var identifier = Reference.ByCodename("personas"); // var identifier = Reference.ByExternalId("Tax-Group-123"); - var response = await client.GetTaxonomyGroupAsync(identifier); - - Assert.NotNull(response); + var response = (await client.GetTaxonomyGroupAsync(identifier)).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_get_taxonomy_groups - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task GetTaxonomyGroups() { var client = MockClientFactory.CreateForSample(SampleFolder, "TaxonomyGroups.json"); + // DocSection: cm_api_v2_get_taxonomy_groups + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var count = (await client.ListTaxonomyGroupsAsync()).EnsureSuccess().Count; + // EndDocSection Assert.Equal(1, count); } - // DocSection: cm_api_v2_get_type - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task GetContentType() { var client = MockClientFactory.CreateForSample(SampleFolder, "ContentType.json"); + // DocSection: cm_api_v2_get_type + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = Reference.ById(Guid.Parse("269202ad-1d9d-47fd-b3e8-bdb05b3e3cf0")); // var identifier = Reference.ByCodename("new_article"); // var identifier = Reference.ByExternalId("article"); - var response = await client.GetContentTypeAsync(identifier); - - Assert.NotNull(response); + var response = (await client.GetContentTypeAsync(identifier)).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_get_types - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task GetContentTypes() { var client = MockClientFactory.CreateForSample(SampleFolder, "ContentTypes.json"); + // DocSection: cm_api_v2_get_types + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net IReadOnlyList response = (await client.ListContentTypesAsync()).EnsureSuccess(); + // EndDocSection Assert.Single(response); } - // DocSection: cm_api_v2_get_variant - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task GetLanguageVariant() { var client = MockClientFactory.CreateForSample(SampleFolder, "LanguageVariant.json"); + // DocSection: cm_api_v2_get_variant + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = LanguageVariantIdentifier.ByIds(Guid.Parse("f4b3fc05-e988-4dae-9ac1-a94aba566474"), Guid.Parse("d1f95fde-af02-b3b5-bd9e-f232311ccab8")); // var identifier = LanguageVariantIdentifier.ByCodenames("on_roasts", "es-ES"); - var response = await client.GetLanguageVariantAsync(identifier); - - Assert.NotNull(response); + var response = (await client.GetLanguageVariantAsync(identifier)).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_get_published_variant - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task GetPublishedLanguageVariant() { var client = MockClientFactory.CreateForSample(SampleFolder, "LanguageVariant.json"); + // DocSection: cm_api_v2_get_published_variant + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = LanguageVariantIdentifier.ByIds(Guid.Parse("f4b3fc05-e988-4dae-9ac1-a94aba566474"), Guid.Parse("d1f95fde-af02-b3b5-bd9e-f232311ccab8")); // var identifier = LanguageVariantIdentifier.ByCodenames("on_roasts", "es-ES"); - var response = await client.GetPublishedLanguageVariantAsync(identifier); - - Assert.NotNull(response); + var response = (await client.GetPublishedLanguageVariantAsync(identifier)).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_get_variants - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task GetLanguageVariants() { var client = MockClientFactory.CreateForSample(SampleFolder, "LanguageVariants.json"); + // DocSection: cm_api_v2_get_variants + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = Reference.ById(Guid.Parse("f4b3fc05-e988-4dae-9ac1-a94aba566474")); // var identifier = Reference.ByCodename("on_roasts"); // var identifier = Reference.ByExternalId("59713"); var response = (await client.ListLanguageVariantsByItemAsync(identifier)).EnsureSuccess(); + // EndDocSection Assert.Single(response); } - // DocSection: cm_api_v2_get_variants_of_type - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task GetLanguageVariantsByType() { var client = MockClientFactory.CreateForSample(SampleFolder, "LanguageVariantsOfType.json"); + // DocSection: cm_api_v2_get_variants_of_type + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = Reference.ById(Guid.Parse("b7aa4a53-d9b1-48cf-b7a6-ed0b182c4b89")); // var identifier = Reference.ByCodename("article"); // var identifier = Reference.ByExternalId("my-article-id"); IReadOnlyList response = (await client.ListLanguageVariantsByTypeAsync(identifier)).EnsureSuccess(); + // EndDocSection Assert.Single(response); } - // DocSection: cm_api_v2_get_components_of_type - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + // Same endpoint and identifier as GetComponentsOfType, which is the one the docs extract under + // cm_api_v2_get_components_of_type; this one stays as a test over a different fixture. [Fact] public async Task GetVariantsWithComponentsOfType() { @@ -510,153 +507,155 @@ public async Task GetVariantsWithComponentsOfType() Assert.Single(response); } - // DocSection: cm_api_v2_get_webhook - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task GetWebhook() { var client = MockClientFactory.CreateForSample(SampleFolder, "Webhook.json"); + // DocSection: cm_api_v2_get_webhook + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = Reference.ById(Guid.Parse("5df74e27-1213-484e-b9ae-bcbe90bd5990")); - var response = await client.GetWebhookAsync(identifier); - - Assert.NotNull(response); + var response = (await client.GetWebhookAsync(identifier)).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_get_webhooks - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task GetWebhooks() { var client = MockClientFactory.CreateForSample(SampleFolder, "Webhooks.json"); + // DocSection: cm_api_v2_get_webhooks + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var response = (await client.ListWebhooksAsync()).EnsureSuccess(); + // EndDocSection Assert.Single(response); } - // DocSection: cm_api_v2_get_workflows - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task GetWorkflows() { var client = MockClientFactory.CreateForSample(SampleFolder, "Workflows.json"); + // DocSection: cm_api_v2_get_workflows + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var response = (await client.ListWorkflowsAsync()).EnsureSuccess(); + // EndDocSection Assert.Equal(2, response.Count); } - // DocSection: cm_api_v2_get_role - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task GetRole() { var client = MockClientFactory.CreateForSample(SampleFolder, "ProjectRole.json"); + // DocSection: cm_api_v2_get_role + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = Reference.ById(Guid.Parse("a23d3727-3b16-4d94-9eb0-85225d29cfef")); //var identifier = Reference.ByCodename("project-manager"); - var response = await client.GetEnvironmentRoleAsync(identifier); - - Assert.NotNull(response); + var response = (await client.GetEnvironmentRoleAsync(identifier)).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_get_roles - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task GetRoles() { var client = MockClientFactory.CreateForSample(SampleFolder, "ProjectRoles.json"); + // DocSection: cm_api_v2_get_roles + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var response = (await client.ListEnvironmentRolesAsync()).EnsureSuccess(); + // EndDocSection Assert.Equal(2, response.Count); } - // DocSection: cm_api_v2_get_subscription_user - // Tip: Find more about .NET SDKs at https://docs.kontent.ai/net [Fact] public async Task GetSubscriptionUser() { var client = MockClientFactory.CreateForSample(SampleFolder, "SubscriptionUser.json"); + // DocSection: cm_api_v2_get_subscription_user + // Tip: Find more about .NET SDKs at https://docs.kontent.ai/net var identifier = UserIdentifier.ByEmail("Joe.Joe@kontent.ai"); //var identifier = UserIdentifier.ById("usr_0vKjTCH2TkO687K3y3bKNS"); - var response = await client.GetSubscriptionUserAsync(identifier); - - Assert.NotNull(response); + var response = (await client.GetSubscriptionUserAsync(identifier)).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_get_subscription_users - // Tip: Find more about .NET SDKs at https://docs.kontent.ai/net [Fact] public async Task GetSubscriptionUsers() { var client = MockClientFactory.CreateForSample(SampleFolder, "SubscriptionUsers.json"); + // DocSection: cm_api_v2_get_subscription_users + // Tip: Find more about .NET SDKs at https://docs.kontent.ai/net var count = (await client.ListSubscriptionUsersAsync()).EnsureSuccess().Count; + // EndDocSection Assert.Equal(2, count); } - // DocSection: cm_api_v2_get_subscription_projects - // Tip: Find more about .NET SDKs at https://docs.kontent.ai/net [Fact] public async Task GetSubscriptionProjects() { var client = MockClientFactory.CreateForSample(SampleFolder, "SubscriptionProjects.json"); + // DocSection: cm_api_v2_get_subscription_projects + // Tip: Find more about .NET SDKs at https://docs.kontent.ai/net var count = (await client.ListSubscriptionProjectsAsync()).EnsureSuccess().Count; + // EndDocSection Assert.Equal(2, count); } - // DocSection: cm_api_v2_get_environment_status - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task GetEnvironmentCloningState() { var client = MockClientFactory.CreateForSample(SampleFolder, "EnvironmentCloningState.json"); - var response = await client.GetEnvironmentCloningStateAsync(); - - Assert.NotNull(response); + // DocSection: cm_api_v2_get_environment_status + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + var response = (await client.GetEnvironmentCloningStateAsync()).EnsureSuccess(); + // EndDocSection } - // DocSection: mapi_v2_get_validation_task - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task GetValidationTask() { var client = MockClientFactory.CreateForSample(SampleFolder, "AsyncValidationTask.json"); - var response = await client.GetAsyncValidationTaskAsync(Guid.Parse("88d94fed-4899-4944-9b4b-c919b11a9db0")); - - Assert.NotNull(response); + // DocSection: mapi_v2_get_validation_task + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + var response = (await client.GetAsyncValidationTaskAsync(Guid.Parse("88d94fed-4899-4944-9b4b-c919b11a9db0"))).EnsureSuccess(); + // EndDocSection } - // DocSection: mapi_v2_get_validation_issues - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task GetValidationIssues() { var client = MockClientFactory.CreateForSample(SampleFolder, "AsyncValidationTaskIssues.json"); + // DocSection: mapi_v2_get_validation_issues + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var result = await client.ListAsyncValidationTaskIssuesAsync(Guid.Parse("88d94fed-4899-4944-9b4b-c919b11a9db0")); + // EndDocSection + Assert.True(result.IsSuccess); } - // DocSection: cm_api_v2_patch_asset_folders - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PatchAssetFolders() { var client = MockClientFactory.CreateForSample(SampleFolder, "PatchAssetsFolderResponse.json"); + // DocSection: cm_api_v2_patch_asset_folders + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var response = (await client.ModifyAssetFoldersAsync( [ new AssetFolderAddIntoPatchModel @@ -679,18 +678,19 @@ public async Task PatchAssetFolders() Value = "Legal documents" } ])).EnsureSuccess(); + // EndDocSection Assert.Equal(3, response.Folders.Count()); Assert.Single(response.Folders.Skip(1).First().Folders!); } - // DocSection: cm_api_v2_patch_content_collections - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PatchContentCollections() { var client = MockClientFactory.CreateForSample(SampleFolder, "Collections.json"); + // DocSection: cm_api_v2_patch_content_collections + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var response = (await client.ModifyCollectionsAsync( [ new CollectionAddIntoPatchModel @@ -718,43 +718,43 @@ public async Task PatchContentCollections() Reference = Reference.ByCodename("second_collection") } ])).EnsureSuccess(); + // EndDocSection Assert.Equal(2, response.Collections.Count()); } - // DocSection: cm_api_v2_patch_language - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PatchLanguage() { var client = MockClientFactory.CreateForSample(SampleFolder, "PatchLanguageResponse.json"); + // DocSection: cm_api_v2_patch_language + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = Reference.ById(Guid.Parse("2ea66788-d3b8-5ff5-b37e-258502e4fd5d")); // var identifier = Reference.ByCodename("de-DE"); // var identifier = Reference.ByExternalId("standard-german"); - var response = await client.ModifyLanguageAsync(identifier, + var response = (await client.ModifyLanguageAsync(identifier, [ LanguagePatch.FallbackLanguage(Reference.ByCodename("en-US")), LanguagePatch.Name("Deutsch"), - ]); - - Assert.NotNull(response); + ])).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_patch_snippet - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PatchSnippet() { var client = MockClientFactory.CreateForSample(SampleFolder, "PatchSnippetResponse.json"); + // DocSection: cm_api_v2_patch_snippet + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = Reference.ById(Guid.Parse("baf884be-531f-441f-ae88-64205efdd0f6")); // var identifier = Reference.ByCodename("my_metadata_snippet"); // var identifier = Reference.ByExternalId("my-metadata-snippet-id"); - var response = await client.ModifyContentTypeSnippetAsync(identifier, + var response = (await client.ModifyContentTypeSnippetAsync(identifier, [ ContentTypeSnippetPatch.ReplaceName("A new snippet name"), ContentTypeSnippetPatch.ReplaceGuidelines( @@ -777,23 +777,22 @@ public async Task PatchSnippet() Reference.ByExternalId("my-multiple-choice-id"), Reference.ById(Guid.Parse("8e6ec8b1-6510-4b9b-b4be-6c977f4bdfbc")), Reference.ById(Guid.Parse("6bfe5a60-5cc2-4303-8f72-9cc53431046b"))) - ]); - - Assert.NotNull(response); + ])).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_patch_taxonomy_group - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PatchTaxonomyGroup() { var client = MockClientFactory.CreateForSample(SampleFolder, "PatchTaxonomyGroupResponse.json"); + // DocSection: cm_api_v2_patch_taxonomy_group + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = Reference.ById(Guid.Parse("0be13600-e57c-577d-8108-c8d860330985")); // var identifier = Reference.ByCodename("personas"); // var identifier = Reference.ByExternalId("Tax-Group-123"); - var response = await client.ModifyTaxonomyGroupAsync(identifier, + var response = (await client.ModifyTaxonomyGroupAsync(identifier, [ TaxonomyGroupPatch.ReplaceName(identifier, "Categories"), TaxonomyGroupPatch.ReplaceCodename(identifier, "category"), @@ -828,23 +827,22 @@ public async Task PatchTaxonomyGroup() Reference = Reference.ByExternalId("my-new-term"), Before = Reference.ByCodename("first_term") } - ]); - - Assert.NotNull(response); + ])).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_patch_type - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PatchContentType() { var client = MockClientFactory.CreateForSample(SampleFolder, "PatchContentTypeResponse.json"); + // DocSection: cm_api_v2_patch_type + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = Reference.ById(Guid.Parse("0be13600-e57c-577d-8108-c8d860330985")); // var identifier = Reference.ByCodename("my_article"); // var identifier = Reference.ByExternalId("my-article-id"); - var response = await client.ModifyContentTypeAsync(identifier, + var response = (await client.ModifyContentTypeAsync(identifier, [ ContentTypePatch.ReplaceName("A new type name"), ContentTypePatch.ReplaceGuidelines( @@ -867,37 +865,35 @@ public async Task PatchContentType() Reference.ByExternalId("my-multiple-choice-id"), Reference.ById(Guid.Parse("d66ffa49-86ff-eeaa-c33b-e5d9eefe8b81")), Reference.ById(Guid.Parse("523e6231-8d80-a158-3601-dffde4e64a78"))) - ]); - - Assert.NotNull(response); + ])).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_patch_environment - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PatchEnvironment() { var client = MockClientFactory.CreateForSample(SampleFolder, "Environment.json"); - var response = await client.ModifyEnvironmentAsync( + // DocSection: cm_api_v2_patch_environment + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + var response = (await client.ModifyEnvironmentAsync( [ new EnvironmentRenamePatchModel { Value = "My Little Production" } - ]); - - Assert.NotNull(response); + ])).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_post_asset - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PostAsset() { var client = MockClientFactory.CreateForSample(SampleFolder, "PostAssetResponse.json"); - var response = await client.CreateAssetAsync(new AssetCreateModel + // DocSection: cm_api_v2_post_asset + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + var response = (await client.CreateAssetAsync(new AssetCreateModel { FileReference = new FileReference { @@ -931,19 +927,18 @@ public async Task PostAsset() ] } ] - }); - - Assert.NotNull(response); + })).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_post_asset_folders - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PostAssetFolders() { var client = MockClientFactory.CreateForSample(SampleFolder, "PostAssetFoldersResponse.json"); - var response = await client.CreateAssetFoldersAsync(new AssetFolderCreateModel + // DocSection: cm_api_v2_post_asset_folders + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + var response = (await client.CreateAssetFoldersAsync(new AssetFolderCreateModel { Folders = [ @@ -961,22 +956,21 @@ public async Task PostAssetFolders() ] } ] - }); - - Assert.NotNull(response); + })).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_post_rendition - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PostAssetRendition() { var client = MockClientFactory.CreateForSample(SampleFolder, "AssetRendition.json"); + // DocSection: cm_api_v2_post_rendition + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var assetReference = Reference.ById(Guid.Parse("fcbb12e6-66a3-4672-85d9-d502d16b8d9c")); // var assetReference = Reference.ByExternalId("which-brewing-fits-you"); - var response = await client.CreateAssetRenditionAsync(assetReference, new AssetRenditionCreateModel + var response = (await client.CreateAssetRenditionAsync(assetReference, new AssetRenditionCreateModel { ExternalId = "hero-image-rendition", Transformation = new RectangleResizeTransformation @@ -988,74 +982,70 @@ public async Task PostAssetRendition() Width = 360, Height = 720, } - }); - - Assert.NotNull(response); + })).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_post_file - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PostFile() { var client = MockClientFactory.CreateForSample(SampleFolder, "PostFileResponse.json"); + // DocSection: cm_api_v2_post_file + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var filePath = Path.Combine(Environment.CurrentDirectory, "Data", "which-brewing-fits-you-1080px.jpg"); var contentType = "image/jpeg"; // Binary file reference to be used when adding a new asset - var response = await client.UploadFileAsync(new FileContentSource(filePath, contentType)); - - Assert.NotNull(response); + var response = (await client.UploadFileAsync(new FileContentSource(filePath, contentType))).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_post_item - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PostItem() { var client = MockClientFactory.CreateForSample(SampleFolder, "PostItemResponse.json"); - var response = await client.CreateContentItemAsync(new ContentItemCreateModel + // DocSection: cm_api_v2_post_item + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + var response = (await client.CreateContentItemAsync(new ContentItemCreateModel { Name = "On Roasts", Codename = "my_article", Type = Reference.ByCodename("article"), Collection = Reference.ByDefaultCodename(), ExternalId = "59713", - }); - - Assert.NotNull(response); + })).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_post_language - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PostLanguage() { var client = MockClientFactory.CreateForSample(SampleFolder, "PostLanguageResponse.json"); - var response = await client.CreateLanguageAsync(new LanguageCreateModel + // DocSection: cm_api_v2_post_language + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + var response = (await client.CreateLanguageAsync(new LanguageCreateModel { Name = "German (Germany)", Codename = "de-DE", IsActive = true, FallbackLanguage = Reference.ByCodename("de-AT"), ExternalId = "standard-german" - }); - - Assert.NotNull(response); + })).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_post_snippet - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PostSnippet() { var client = MockClientFactory.CreateForSample(SampleFolder, "PostSnippetResponse.json"); - var response = await client.CreateContentTypeSnippetAsync(new ContentTypeSnippetCreateModel + // DocSection: cm_api_v2_post_snippet + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + var response = (await client.CreateContentTypeSnippetAsync(new ContentTypeSnippetCreateModel { Name = "metadata", Codename = "my_metadata", @@ -1077,19 +1067,18 @@ public async Task PostSnippet() ExternalId = "meta_description", } ] - }); - - Assert.NotNull(response); + })).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_post_taxonomy_group - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PostTaxonomyGroup() { var client = MockClientFactory.CreateForSample(SampleFolder, "PostTaxonomyGroupResponse.json"); - var response = await client.CreateTaxonomyGroupAsync(new TaxonomyGroupCreateModel + // DocSection: cm_api_v2_post_taxonomy_group + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + var response = (await client.CreateTaxonomyGroupAsync(new TaxonomyGroupCreateModel { Name = "Personas", ExternalId = "Tax-Group-123", @@ -1137,19 +1126,18 @@ public async Task PostTaxonomyGroup() ] } ] - }); - - Assert.NotNull(response); + })).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_post_type - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PostType() { var client = MockClientFactory.CreateForSample(SampleFolder, "PostTypeResponse.json"); - var response = await client.CreateContentTypeAsync(new ContentTypeCreateModel + // DocSection: cm_api_v2_post_type + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + var response = (await client.CreateContentTypeAsync(new ContentTypeCreateModel { ExternalId = "article", Name = "Article", @@ -1190,31 +1178,29 @@ public async Task PostType() ContentGroup = Reference.ByCodename("author"), }, ] - }); - - Assert.NotNull(response); + })).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_post_validate - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PostValidate() { var client = MockClientFactory.CreateForSample(SampleFolder, "PostValidateResponse.json"); - var response = await client.ValidateEnvironmentAsync(); - - Assert.NotNull(response); + // DocSection: cm_api_v2_post_validate + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + var response = (await client.ValidateEnvironmentAsync()).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_post_webhook - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PostWebhook() { var client = MockClientFactory.CreateForSample(SampleFolder, "PostWebhookResponse.json"); - var response = await client.CreateWebhookAsync(new WebhookCreateModel + // DocSection: cm_api_v2_post_webhook + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + var response = (await client.CreateWebhookAsync(new WebhookCreateModel { Name = "Example webhook", Url = "https://example.com/webhook", @@ -1308,19 +1294,18 @@ public async Task PostWebhook() Slot = DeliverySlot.Preview, Events = WebhookEvents.Specific } - }); - - Assert.NotNull(response); + })).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_post_workflow - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PostWorkflow() { var client = MockClientFactory.CreateForSample(SampleFolder, "Workflow.json"); - var response = await client.CreateWorkflowAsync(new WorkflowUpsertModel + // DocSection: cm_api_v2_post_workflow + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + var response = (await client.CreateWorkflowAsync(new WorkflowUpsertModel { Name = "My workflow", Scopes = @@ -1366,19 +1351,18 @@ public async Task PostWorkflow() UnpublishRoleIds = [Guid.Parse("e796887c-38a1-4ab2-a999-c40861bb7a4b")] }, ArchivedStep = new WorkflowArchivedStepUpsertModel() - }); - - Assert.NotNull(response); + })).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_post_user - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PostUser() { var client = MockClientFactory.CreateForSample(SampleFolder, "ProjectUser.json"); - var response = await client.InviteUserIntoEnvironmentAsync(new UserInviteModel + // DocSection: cm_api_v2_post_user + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + var response = (await client.InviteUserIntoEnvironmentAsync(new UserInviteModel { Email = "user@example.com", CollectionGroups = @@ -1400,19 +1384,18 @@ public async Task PostUser() ] } ] - }); - - Assert.NotNull(response); + })).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_clone_environment - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PostCloneEnvironment() { var client = MockClientFactory.CreateForSample(SampleFolder, "ClonedEnvironment.json"); - var response = await client.CloneEnvironmentAsync(new EnvironmentCloneModel + // DocSection: cm_api_v2_clone_environment + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + var response = (await client.CloneEnvironmentAsync(new EnvironmentCloneModel { Name = "New environment", RolesToActivate = [Guid.Parse("2f925111-1457-49d4-a595-0958feae8ae4")], @@ -1421,30 +1404,28 @@ public async Task PostCloneEnvironment() ContentItemsAssets = true, ContentItemVersionHistory = false } - }); - - Assert.NotNull(response); + })).EnsureSuccess(); + // EndDocSection } - // DocSection: mapi_v2_post_validate_async - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PostValidateEnvironment() { var client = MockClientFactory.CreateForSample(SampleFolder, "AsyncValidationTask.json"); - var response = await client.InitiateEnvironmentAsyncValidationTaskAsync(); - - Assert.NotNull(response); + // DocSection: mapi_v2_post_validate_async + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + var response = (await client.InitiateEnvironmentAsyncValidationTaskAsync()).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_put_asset - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PutAsset() { var client = MockClientFactory.CreateForSample(SampleFolder, "PutAssetResponse.json"); + // DocSection: cm_api_v2_put_asset + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = Reference.ByExternalId("which-brewing-fits-you"); // var identifier = Reference.ById(Guid.Parse("fcbb12e6-66a3-4672-85d9-d502d16b8d9c")); @@ -1515,22 +1496,23 @@ public async Task PutAsset() } ] }); + // EndDocSection Assert.NotNull(createdAssetResponse); Assert.NotNull(updatedAssetResponse); } - // DocSection: cm_api_v2_put_rendition - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PutAssetRendition() { var client = MockClientFactory.CreateForSample(SampleFolder, "AssetRendition.json"); + // DocSection: cm_api_v2_put_rendition + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = AssetRenditionIdentifier.ByIds(Guid.Parse("fcbb12e6-66a3-4672-85d9-d502d16b8d9c"), Guid.Parse("ce559491-0fc1-494b-96f3-244bc095de57")); // var identifier = new AssetRenditionIdentifier(Reference.ByExternalId("which-brewing-fits-you"), Reference.ByExternalId("hero-image-rendition")); - var response = await client.UpdateAssetRenditionAsync(identifier, new AssetRenditionUpdateModel() + var response = (await client.UpdateAssetRenditionAsync(identifier, new AssetRenditionUpdateModel() { Transformation = new RectangleResizeTransformation { @@ -1541,18 +1523,17 @@ public async Task PutAssetRendition() Width = 360, Height = 720, } - }); - - Assert.NotNull(response); + })).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_put_item - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PutItem() { var client = MockClientFactory.CreateForSample(SampleFolder, "PutItemResponse.json"); + // DocSection: cm_api_v2_put_item + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = Reference.ByExternalId("59713"); // var identifier = Reference.ById(Guid.Parse("f4b3fc05-e988-4dae-9ac1-a94aba566474")); // var identifier = Reference.ByCodename("my_article"); @@ -1565,21 +1546,22 @@ public async Task PutItem() // 'Type' is only required when creating a new content item Type = Reference.ByCodename("article"), }); + // EndDocSection Assert.NotNull(upsertedItemResponse); } - // DocSection: cm_api_v2_put_variant - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PutLanguageVariant() { var client = MockClientFactory.CreateForSample(SampleFolder, "PutLanguageVariantResponse.json"); + // DocSection: cm_api_v2_put_variant + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = LanguageVariantIdentifier.ByIds(Guid.Parse("f4b3fc05-e988-4dae-9ac1-a94aba566474"), Guid.Parse("d1f95fde-af02-b3b5-bd9e-f232311ccab8")); // var identifier = LanguageVariantIdentifier.ByCodenames("my_article", "es-ES"); - var response = await client.UpsertLanguageVariantAsync( + var response = (await client.UpsertLanguageVariantAsync( identifier, new LanguageVariantUpsertModel { @@ -1624,113 +1606,106 @@ public async Task PutLanguageVariant() ], DueDate = new DueDateModel { - Value = DateTime.Parse("2092-01-07T06:04:00.7069564Z", CultureInfo.InvariantCulture) + Value = new DateTimeOffset(2092, 1, 7, 6, 4, 0, TimeSpan.Zero) }, Workflow = new WorkflowStepIdentifier(Reference.ByDefaultCodename(), Reference.ByCodename("review")) - }); - - Assert.NotNull(response); + })).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_put_variant_cancel_schedule - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PutLanguageVariantCancelSchedule() { var client = MockClientFactory.CreateForSample(SampleFolder, "Empty.json"); + // DocSection: cm_api_v2_put_variant_cancel_schedule + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = LanguageVariantIdentifier.ByIds(Guid.Parse("f4b3fc05-e988-4dae-9ac1-a94aba566474"), Guid.Parse("d1f95fde-af02-b3b5-bd9e-f232311ccab8")); // var identifier = LanguageVariantIdentifier.ByCodenames("my_article", "es-ES"); - var exception = await Record.ExceptionAsync(async () => await client.CancelPublishingOfLanguageVariantAsync(identifier)); - - Assert.Null(exception); + (await client.CancelPublishingOfLanguageVariantAsync(identifier)).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_put_var_cancel_sched_unpublish - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PutCancelUnpublishingOfLanguageVariant() { var client = MockClientFactory.CreateForSample(SampleFolder, "Empty.json"); + // DocSection: cm_api_v2_put_var_cancel_sched_unpublish + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = LanguageVariantIdentifier.ByIds(Guid.Parse("f4b3fc05-e988-4dae-9ac1-a94aba566474"), Guid.Parse("d1f95fde-af02-b3b5-bd9e-f232311ccab8")); // var identifier = LanguageVariantIdentifier.ByCodenames("my_article", "es-ES"); - var exception = await Record.ExceptionAsync(async () => await client.CancelUnpublishingOfLanguageVariantAsync(identifier)); - - Assert.Null(exception); + (await client.CancelUnpublishingOfLanguageVariantAsync(identifier)).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_put_variant_create_new_version - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PutLanguageVariantNewVersion() { var client = MockClientFactory.CreateForSample(SampleFolder, "Empty.json"); + // DocSection: cm_api_v2_put_variant_create_new_version + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = LanguageVariantIdentifier.ByIds(Guid.Parse("f4b3fc05-e988-4dae-9ac1-a94aba566474"), Guid.Parse("d1f95fde-af02-b3b5-bd9e-f232311ccab8")); // var identifier = LanguageVariantIdentifier.ByCodenames("my_article", "es-ES"); - var exception = await Record.ExceptionAsync(async () => await client.CreateNewVersionOfLanguageVariantAsync(identifier)); - Assert.Null(exception); + (await client.CreateNewVersionOfLanguageVariantAsync(identifier)).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_put_variant_publish_or_schedule - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PutPublishLanguageVariant() { var client = MockClientFactory.CreateForSample(SampleFolder, "Empty.json"); + // DocSection: cm_api_v2_put_variant_publish_or_schedule + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = LanguageVariantIdentifier.ByIds(Guid.Parse("f4b3fc05-e988-4dae-9ac1-a94aba566474"), Guid.Parse("d1f95fde-af02-b3b5-bd9e-f232311ccab8")); // var identifier = LanguageVariantIdentifier.ByCodenames("my_article", "es-ES"); // Immediate publish - var immediateException = await Record.ExceptionAsync(async () => await client.PublishLanguageVariantAsync(identifier)); + (await client.PublishLanguageVariantAsync(identifier)).EnsureSuccess(); // Scheduled publish - var scheduledPublishException = await Record.ExceptionAsync(async () => await client.SchedulePublishingOfLanguageVariantAsync(identifier, new ScheduleModel + (await client.SchedulePublishingOfLanguageVariantAsync(identifier, new ScheduleModel { - ScheduledTo = DateTime.Parse("2038-01-19T04:14:08", CultureInfo.InvariantCulture), + ScheduledTo = new DateTimeOffset(2038, 1, 19, 4, 14, 8, TimeSpan.Zero), DisplayTimeZone = "Europe/London" - })); - - Assert.Null(immediateException); - Assert.Null(scheduledPublishException); + })).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_put_variant_unpublish_archive - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PutUnpublishLanguageVariant() { var client = MockClientFactory.CreateForSample(SampleFolder, "Empty.json"); + // DocSection: cm_api_v2_put_variant_unpublish_archive + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = LanguageVariantIdentifier.ByIds(Guid.Parse("f4b3fc05-e988-4dae-9ac1-a94aba566474"), Guid.Parse("d1f95fde-af02-b3b5-bd9e-f232311ccab8")); // var identifier = LanguageVariantIdentifier.ByCodenames("my_article", "es-ES"); // Immediate unpublish - var immediateException = await Record.ExceptionAsync(async () => await client.UnpublishLanguageVariantAsync(identifier)); + (await client.UnpublishLanguageVariantAsync(identifier)).EnsureSuccess(); // Scheduled unpublish - var scheduledUnpublishException = await Record.ExceptionAsync(async () => await client.ScheduleUnpublishingOfLanguageVariantAsync(identifier, new ScheduleModel + (await client.ScheduleUnpublishingOfLanguageVariantAsync(identifier, new ScheduleModel { - ScheduledTo = DateTime.Parse("2038-01-19T04:14:08", CultureInfo.InvariantCulture), + ScheduledTo = new DateTimeOffset(2038, 1, 19, 4, 14, 8, TimeSpan.Zero), DisplayTimeZone = "Europe/London" - })); - - Assert.Null(immediateException); - Assert.Null(scheduledUnpublishException); + })).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_put_variant_workflow - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PutVariantWorkflow() { var client = MockClientFactory.CreateForSample(SampleFolder, "Empty.json"); + // DocSection: cm_api_v2_put_variant_workflow + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var itemIdentifier = Reference.ById(Guid.Parse("f4b3fc05-e988-4dae-9ac1-a94aba566474")); // var itemIdentifier = Reference.ByCodename("my_article"); // var itemIdentifier = Reference.ByExternalId("59713"); @@ -1740,57 +1715,54 @@ public async Task PutVariantWorkflow() var workflowStepIdentifier = Reference.ById(Guid.Parse("16221cc2-bd22-4414-a513-f3e555c0fc93")); - var exception = await Record.ExceptionAsync(async () => - await client.ChangeLanguageVariantWorkflowAsync( - new LanguageVariantIdentifier(itemIdentifier, languageIdentifier), - new ChangeLanguageVariantWorkflowModel(Reference.ByDefaultId(), workflowStepIdentifier) + (await client.ChangeLanguageVariantWorkflowAsync( + new LanguageVariantIdentifier(itemIdentifier, languageIdentifier), + new ChangeLanguageVariantWorkflowModel(Reference.ByDefaultId(), workflowStepIdentifier) + { + DueDate = new DueDateModel { - DueDate = new DueDateModel - { - Value = DateTime.UtcNow.AddDays(42) - }, - Contributors = [UserIdentifier.ByEmail("user@kontent.ai")], - Note = "Moving this to the next workflow step." - } - )); - Assert.Null(exception); + Value = DateTime.UtcNow.AddDays(42) + }, + Contributors = [UserIdentifier.ByEmail("user@kontent.ai")], + Note = "Moving this to the next workflow step." + } + )).EnsureSuccess(); + // EndDocSection } - // DocSection: mapi_v2_disable_webhook - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PutDisableWebhook() { var client = MockClientFactory.CreateForSample(SampleFolder, "Empty.json"); - var exception = await Record.ExceptionAsync(async () => - await client.DisableWebhookAsync(Reference.ById(Guid.Parse("5df74e27-1213-484e-b9ae-bcbe90bd5990")))); - Assert.Null(exception); + // DocSection: mapi_v2_disable_webhook + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + (await client.DisableWebhookAsync(Reference.ById(Guid.Parse("5df74e27-1213-484e-b9ae-bcbe90bd5990")))).EnsureSuccess(); + // EndDocSection } - // DocSection: mapi_v2_enable_webhook - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PutEnableWebhook() { var client = MockClientFactory.CreateForSample(SampleFolder, "Empty.json"); - var exception = await Record.ExceptionAsync(async () => - await client.EnableWebhookAsync(Reference.ById(Guid.Parse("5df74e27-1213-484e-b9ae-bcbe90bd5990")))); - Assert.Null(exception); + // DocSection: mapi_v2_enable_webhook + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + (await client.EnableWebhookAsync(Reference.ById(Guid.Parse("5df74e27-1213-484e-b9ae-bcbe90bd5990")))).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_put_workflow - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PutWorkflow() { var client = MockClientFactory.CreateForSample(SampleFolder, "Workflow.json"); + // DocSection: cm_api_v2_put_workflow + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = Reference.ByCodename("my_workflow"); // var identifier = Reference.ById(Guid.Parse("f4b3fc05-e988-4dae-9ac1-a94aba566474")); - var response = await client.UpdateWorkflowAsync(identifier, new WorkflowUpsertModel + var response = (await client.UpdateWorkflowAsync(identifier, new WorkflowUpsertModel { Name = "My workflow", Scopes = @@ -1836,22 +1808,21 @@ public async Task PutWorkflow() UnpublishRoleIds = [Guid.Parse("e796887c-38a1-4ab2-a999-c40861bb7a4b")] }, ArchivedStep = new WorkflowArchivedStepUpsertModel() - }); - - Assert.NotNull(response); + })).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_put_user - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PutUser() { var client = MockClientFactory.CreateForSample(SampleFolder, "ProjectUser.json"); + // DocSection: cm_api_v2_put_user + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = UserIdentifier.ByEmail("user@kontent.ai"); //var identifier = UserIdentifier.ById("d94bc87a-c066-48a1-a910-4f991ccc1fb5"); - var response = await client.UpdateUserRolesAsync( + var response = (await client.UpdateUserRolesAsync( identifier, new UserRolesUpdateModel { @@ -1870,56 +1841,49 @@ public async Task PutUser() ] } ] - }); - - Assert.NotNull(response); + })).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_put_subscription_user_activate - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PutSubscriptionUserActivate() { var client = MockClientFactory.CreateForSample(SampleFolder); + // DocSection: cm_api_v2_put_subscription_user_activate + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = UserIdentifier.ByEmail("user@kontent.ai"); //var identifier = UserIdentifier.ById("d94bc87a-c066-48a1-a910-4f991ccc1fb5"); - var exception = await Record.ExceptionAsync( - async () => await client.ActivateSubscriptionUserAsync(identifier)); - - Assert.Null(exception); + (await client.ActivateSubscriptionUserAsync(identifier)).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_put_subscription_user_deactivate - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PutSubscriptionUserDeactivate() { var client = MockClientFactory.CreateForSample(SampleFolder); + // DocSection: cm_api_v2_put_subscription_user_deactivate + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = UserIdentifier.ByEmail("user@kontent.ai"); //var identifier = UserIdentifier.ById("d94bc87a-c066-48a1-a910-4f991ccc1fb5"); - var exception = await Record.ExceptionAsync( - async () => await client.DeactivateSubscriptionUserAsync(identifier)); - - Assert.Null(exception); + (await client.DeactivateSubscriptionUserAsync(identifier)).EnsureSuccess(); + // EndDocSection } - // DocSection: cm_api_v2_mark_environment_as_production - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PutMarkEnvironmentAsProduction() { var client = MockClientFactory.CreateForSample(SampleFolder); - var exception = await Record.ExceptionAsync( - async () => await client.MarkEnvironmentAsProductionAsync(new MarkAsProductionModel - { - EnableWebhooks = true - })); - - Assert.Null(exception); + // DocSection: cm_api_v2_mark_environment_as_production + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + (await client.MarkEnvironmentAsProductionAsync(new MarkAsProductionModel + { + EnableWebhooks = true + })).EnsureSuccess(); + // EndDocSection } } diff --git a/src/management/Kontent.Ai.Management.Tests/CodeSamples/DocSectionMarkerTests.cs b/src/management/Kontent.Ai.Management.Tests/CodeSamples/DocSectionMarkerTests.cs new file mode 100644 index 000000000..04c92e007 --- /dev/null +++ b/src/management/Kontent.Ai.Management.Tests/CodeSamples/DocSectionMarkerTests.cs @@ -0,0 +1,114 @@ +using System.Text.RegularExpressions; +using AwesomeAssertions; + +namespace Kontent.Ai.Management.Tests.CodeSamples; + +/// +/// The samples in this folder are the source for +/// https://github.com/Kontent-ai-Learn/kontent-ai-learn-code-samples/tree/master/net, where each marked +/// section is published as the file named by its id. Two properties have to hold for that to work, and +/// neither shows up as a failing test anywhere else: a section that is never closed runs on into the next +/// method's scaffolding, and a duplicated id has no single file it belongs to. +/// +public partial class DocSectionMarkerTests +{ + [GeneratedRegex(@"^\s*//\s*DocSection:\s*(\S+)\s*$")] + private static partial Regex OpenMarker(); + + [GeneratedRegex(@"^\s*//\s*EndDocSection\s*$")] + private static partial Regex CloseMarker(); + + private static IEnumerable SampleFiles() => + Directory.EnumerateFiles( + Path.Combine(Environment.CurrentDirectory, "..", "..", "..", "CodeSamples"), "*.cs"); + + [Fact] + public void EverySection_IsClosed() + { + var unclosed = new List(); + + foreach (var file in SampleFiles()) + { + string? open = null; + foreach (var line in File.ReadLines(file)) + { + var opened = OpenMarker().Match(line); + if (opened.Success) + { + if (open is not null) + { + unclosed.Add($"{Path.GetFileName(file)}: {open}"); + } + + open = opened.Groups[1].Value; + } + else if (CloseMarker().IsMatch(line)) + { + open = null; + } + } + + if (open is not null) + { + unclosed.Add($"{Path.GetFileName(file)}: {open}"); + } + } + + unclosed.Should().BeEmpty("an unclosed section runs to the next marker and publishes the test scaffolding between them"); + } + + [Fact] + public void EveryId_IsUsedOnce() + { + var ids = SampleFiles() + .SelectMany(File.ReadLines) + .Select(line => OpenMarker().Match(line)) + .Where(match => match.Success) + .Select(match => match.Groups[1].Value); + + var duplicates = ids + .GroupBy(id => id, StringComparer.Ordinal) + .Where(group => group.Count() > 1) + .Select(group => group.Key); + + duplicates.Should().BeEmpty("the id names the published file, so two sections cannot share one"); + } + + [Fact] + public void NoSection_ContainsTestScaffolding() + { + var leaks = new List(); + + foreach (var file in SampleFiles()) + { + string? open = null; + foreach (var line in File.ReadLines(file)) + { + var opened = OpenMarker().Match(line); + if (opened.Success) + { + open = opened.Groups[1].Value; + continue; + } + + if (CloseMarker().IsMatch(line)) + { + open = null; + continue; + } + + if (open is null) continue; + + if (line.Contains("MockClientFactory") || + line.Contains("[Fact]") || + line.Contains("Record.ExceptionAsync") || + line.TrimStart().StartsWith("Assert.", StringComparison.Ordinal)) + { + leaks.Add($"{Path.GetFileName(file)}: {open} -> {line.Trim()}"); + } + } + } + + leaks.Should().BeEmpty("whatever sits between the markers is published verbatim as the sample"); + } +} diff --git a/src/management/Kontent.Ai.Management.Tests/CodeSamples/ImportAssets.cs b/src/management/Kontent.Ai.Management.Tests/CodeSamples/ImportAssets.cs index f6be52774..b13dbe917 100644 --- a/src/management/Kontent.Ai.Management.Tests/CodeSamples/ImportAssets.cs +++ b/src/management/Kontent.Ai.Management.Tests/CodeSamples/ImportAssets.cs @@ -16,18 +16,18 @@ public class ImportAssets private const string SampleFolder = "CodeSamples"; - // DocSection: importing_assets_create_asset - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task CreateAsset() { - var client = MockClientFactory.CreateForSample(SampleFolder, "Empty.json"); + var client = MockClientFactory.CreateForSample(SampleFolder, "ImportedFile.json", "ImportedAsset.json"); + // DocSection: importing_assets_create_asset + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var filePath = Path.Combine(Environment.CurrentDirectory, "Data", "brno-cafe-1080px.jpg"); var contentType = "image/jpg"; // Uploads the file and creates or updates the asset that references it in a single call - var createdAssetResponse = await client.UpsertAssetAsync( + var createdAssetResponse = (await client.UpsertAssetAsync( Reference.ByExternalId("which-brewing-fits-you"), new FileContentSource(filePath, contentType), new AssetUpsertModel @@ -46,33 +46,35 @@ public async Task CreateAsset() Language = Reference.ByCodename("es-ES") } ] - }); + })).EnsureSuccess(); + // EndDocSection } - // DocSection: importing_assets_upload_file - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task UploadingFiles() { - var client = MockClientFactory.CreateForSample(SampleFolder, "Empty.json"); + var client = MockClientFactory.CreateForSample(SampleFolder, "ImportedFile.json"); + // DocSection: importing_assets_upload_file + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var filePath = Path.Combine(Environment.CurrentDirectory, "Data", "brno-cafe-1080px.jpg"); var contentType = "image/jpg"; // Binary file reference to be used when adding a new asset - var response = await client.UploadFileAsync(new FileContentSource(filePath, contentType)); + var response = (await client.UploadFileAsync(new FileContentSource(filePath, contentType))).EnsureSuccess(); + // EndDocSection } - // DocSection: importing_assets_upload_file - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task UseAsset() { - var client = MockClientFactory.CreateForSample(SampleFolder, "Empty.json"); + var client = MockClientFactory.CreateForSample(SampleFolder, "ImportedLanguageVariant.json"); + // DocSection: importing_assets_use_asset + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = new LanguageVariantIdentifier(Reference.ByExternalId("ext-cafe-brno"), Reference.ByCodename("en-US")); - var response = await client.UpsertLanguageVariantAsync(identifier, new LanguageVariantUpsertModel + var response = (await client.UpsertLanguageVariantAsync(identifier, new LanguageVariantUpsertModel { Elements = [ @@ -85,19 +87,20 @@ public async Task UseAsset() ], }, ] - }); + })).EnsureSuccess(); + // EndDocSection } - // DocSection: importing_assets_upload_file - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task UseAssetRichText() { - var client = MockClientFactory.CreateForSample(SampleFolder, "Empty.json"); + var client = MockClientFactory.CreateForSample(SampleFolder, "ImportedLanguageVariant.json"); + // DocSection: importing_assets_use_asset_rich_text + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = new LanguageVariantIdentifier(Reference.ByExternalId("new-cafes"), Reference.ByCodename("en-US")); - var response = await client.UpsertLanguageVariantAsync(identifier, new LanguageVariantUpsertModel + var response = (await client.UpsertLanguageVariantAsync(identifier, new LanguageVariantUpsertModel { Elements = [ @@ -107,6 +110,7 @@ public async Task UseAssetRichText() Value = "

...

", }, ] - }); + })).EnsureSuccess(); + // EndDocSection } } diff --git a/src/management/Kontent.Ai.Management.Tests/CodeSamples/ImportContentItems.cs b/src/management/Kontent.Ai.Management.Tests/CodeSamples/ImportContentItems.cs index 96ea59e28..b465fccd9 100644 --- a/src/management/Kontent.Ai.Management.Tests/CodeSamples/ImportContentItems.cs +++ b/src/management/Kontent.Ai.Management.Tests/CodeSamples/ImportContentItems.cs @@ -16,26 +16,27 @@ public class ImportContentItems private const string SampleFolder = "CodeSamples"; - // DocSection: importing_create_item - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task CreateContentItem() { - var client = MockClientFactory.CreateForSample(SampleFolder, "Empty.json"); + var client = MockClientFactory.CreateForSample(SampleFolder, "ImportedContentItem.json"); - await client.UpsertContentItemAsync( + // DocSection: importing_create_item + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + (await client.UpsertContentItemAsync( Reference.ByExternalId("ext-cafe-brno"), - new ContentItemUpsertModel { Name = "Brno", Type = Reference.ByExternalId("cafe") }); + new ContentItemUpsertModel { Name = "Brno", Type = Reference.ByExternalId("cafe") })).EnsureSuccess(); + // EndDocSection } - // DocSection: importing_create_type - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task CreateContentType() { - var client = MockClientFactory.CreateForSample(SampleFolder, "Empty.json"); + var client = MockClientFactory.CreateForSample(SampleFolder, "ImportedContentType.json"); - var response = await client.CreateContentTypeAsync(new ContentTypeCreateModel + // DocSection: importing_create_type + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + var response = (await client.CreateContentTypeAsync(new ContentTypeCreateModel { Codename = "cafe", Name = "Cafe", @@ -93,19 +94,20 @@ public async Task CreateContentType() Codename = "photo" } ] - }); + })).EnsureSuccess(); + // EndDocSection } - // DocSection: importing_upsert_variant - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task UpsertLanguageVariant() { - var client = MockClientFactory.CreateForSample(SampleFolder, "Empty.json"); + var client = MockClientFactory.CreateForSample(SampleFolder, "ImportedLanguageVariant.json"); + // DocSection: importing_upsert_variant + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = new LanguageVariantIdentifier(Reference.ByExternalId("ext-cafe-brno"), Reference.ByCodename("en-US")); - var response = await client.UpsertLanguageVariantAsync(identifier, new LanguageVariantUpsertModel + var response = (await client.UpsertLanguageVariantAsync(identifier, new LanguageVariantUpsertModel { Elements = [ @@ -117,6 +119,7 @@ public async Task UpsertLanguageVariant() new TextElement { Element = Reference.ByExternalId("phone"), Value = "+420 555 555 555" }, new TextElement { Element = Reference.ByExternalId("email"), Value = "brnocafe@kontent.ai" }, ] - }); + })).EnsureSuccess(); + // EndDocSection } } diff --git a/src/management/Kontent.Ai.Management.Tests/CodeSamples/ImportContentModel.cs b/src/management/Kontent.Ai.Management.Tests/CodeSamples/ImportContentModel.cs index 16748e275..06eb03a9f 100644 --- a/src/management/Kontent.Ai.Management.Tests/CodeSamples/ImportContentModel.cs +++ b/src/management/Kontent.Ai.Management.Tests/CodeSamples/ImportContentModel.cs @@ -16,14 +16,14 @@ public class ImportContentModel private const string SampleFolder = "CodeSamples"; - // DocSection: import_model_create_snippet - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task CreateSnippet() { - var client = MockClientFactory.CreateForSample(SampleFolder, "Empty.json"); + var client = MockClientFactory.CreateForSample(SampleFolder, "ImportedSnippet.json"); - var response = await client.CreateContentTypeSnippetAsync(new ContentTypeSnippetCreateModel + // DocSection: import_model_create_snippet + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + var response = (await client.CreateContentTypeSnippetAsync(new ContentTypeSnippetCreateModel { Name = "Metadata", Codename = "metadata", @@ -45,17 +45,18 @@ public async Task CreateSnippet() Codename = "description", }, ] - }); + })).EnsureSuccess(); + // EndDocSection } - // DocSection: import_model_create_taxonomy - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task CreateTaxonomy() { - var client = MockClientFactory.CreateForSample(SampleFolder, "Empty.json"); + var client = MockClientFactory.CreateForSample(SampleFolder, "ImportedTaxonomyGroup.json"); - var response = await client.CreateTaxonomyGroupAsync(new TaxonomyGroupCreateModel + // DocSection: import_model_create_taxonomy + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + var response = (await client.CreateTaxonomyGroupAsync(new TaxonomyGroupCreateModel { Name = "Blogpost topic", Codename = "blog_topic", @@ -108,17 +109,18 @@ public async Task CreateTaxonomy() ] }, ] - }); + })).EnsureSuccess(); + // EndDocSection } - // DocSection: import_model_create_type - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task CreateType() { - var client = MockClientFactory.CreateForSample(SampleFolder, "Empty.json"); + var client = MockClientFactory.CreateForSample(SampleFolder, "ImportedContentType.json"); - var response = await client.CreateContentTypeAsync(new ContentTypeCreateModel + // DocSection: import_model_create_type + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + var response = (await client.CreateContentTypeAsync(new ContentTypeCreateModel { Name = "Blogpost", Codename = "blogpost", @@ -171,6 +173,7 @@ public async Task CreateType() ContentGroup = Reference.ByExternalId("topic") } ] - }); + })).EnsureSuccess(); + // EndDocSection } } diff --git a/src/management/Kontent.Ai.Management.Tests/CodeSamples/ImportLinkedContent.cs b/src/management/Kontent.Ai.Management.Tests/CodeSamples/ImportLinkedContent.cs index 105230a6a..06809d047 100644 --- a/src/management/Kontent.Ai.Management.Tests/CodeSamples/ImportLinkedContent.cs +++ b/src/management/Kontent.Ai.Management.Tests/CodeSamples/ImportLinkedContent.cs @@ -14,40 +14,42 @@ public class ImportLinkedContent private const string SampleFolder = "CodeSamples"; - // DocSection: import_linked_create_item - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task CreateItem() { - var client = MockClientFactory.CreateForSample(SampleFolder, "Empty.json"); + var client = MockClientFactory.CreateForSample(SampleFolder, "ImportedContentItem.json"); - await client.UpsertContentItemAsync( + // DocSection: import_linked_create_item + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + (await client.UpsertContentItemAsync( Reference.ByExternalId("123"), - new ContentItemUpsertModel { Name = "On Roasts", Type = Reference.ByCodename("article") }); + new ContentItemUpsertModel { Name = "On Roasts", Type = Reference.ByCodename("article") })).EnsureSuccess(); + // EndDocSection } - // DocSection: import_linked_create_sec_item - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task CreateSecondItem() { - var client = MockClientFactory.CreateForSample(SampleFolder, "Empty.json"); + var client = MockClientFactory.CreateForSample(SampleFolder, "ImportedContentItem.json"); - await client.UpsertContentItemAsync( + // DocSection: import_linked_create_sec_item + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + (await client.UpsertContentItemAsync( Reference.ByExternalId("456"), - new ContentItemUpsertModel { Name = "Donate with us", Type = Reference.ByCodename("article") }); + new ContentItemUpsertModel { Name = "Donate with us", Type = Reference.ByCodename("article") })).EnsureSuccess(); + // EndDocSection } - // DocSection: import_linked_upsert_Sec_variant - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task UpsertSecondVariant() { - var client = MockClientFactory.CreateForSample(SampleFolder, "Empty.json"); + var client = MockClientFactory.CreateForSample(SampleFolder, "ImportedLanguageVariant.json"); + // DocSection: import_linked_upsert_Sec_variant + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = new LanguageVariantIdentifier(Reference.ByExternalId("456"), Reference.ByCodename("en-US")); - await client.UpsertLanguageVariantAsync(identifier, new LanguageVariantUpsertModel + (await client.UpsertLanguageVariantAsync(identifier, new LanguageVariantUpsertModel { Elements = [ @@ -58,19 +60,20 @@ public async Task UpsertSecondVariant() Value = [Reference.ByExternalId("123")], }, ] - }); + })).EnsureSuccess(); + // EndDocSection } - // DocSection: import_linked_upsert_variant - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task UsertVariant() { - var client = MockClientFactory.CreateForSample(SampleFolder, "Empty.json"); + var client = MockClientFactory.CreateForSample(SampleFolder, "ImportedLanguageVariant.json"); + // DocSection: import_linked_upsert_variant + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = new LanguageVariantIdentifier(Reference.ByExternalId("123"), Reference.ByCodename("en-US")); - var response = await client.UpsertLanguageVariantAsync(identifier, new LanguageVariantUpsertModel + var response = (await client.UpsertLanguageVariantAsync(identifier, new LanguageVariantUpsertModel { Elements = [ @@ -81,16 +84,18 @@ public async Task UsertVariant() Value = [Reference.ByExternalId("456")], }, ] - }); + })).EnsureSuccess(); + // EndDocSection } - // DocSection: import_linked_validate_content - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task PostValidate() { - var client = MockClientFactory.CreateForSample(SampleFolder, "Empty.json"); + var client = MockClientFactory.CreateForSample(SampleFolder, "ImportedValidationReport.json"); - var response = await client.ValidateEnvironmentAsync(); + // DocSection: import_linked_validate_content + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + var response = (await client.ValidateEnvironmentAsync()).EnsureSuccess(); + // EndDocSection } } diff --git a/src/management/Kontent.Ai.Management.Tests/CodeSamples/ImportRichText.cs b/src/management/Kontent.Ai.Management.Tests/CodeSamples/ImportRichText.cs index 840284972..a163c2ac6 100644 --- a/src/management/Kontent.Ai.Management.Tests/CodeSamples/ImportRichText.cs +++ b/src/management/Kontent.Ai.Management.Tests/CodeSamples/ImportRichText.cs @@ -16,14 +16,14 @@ public class ImportRichText private const string SampleFolder = "CodeSamples"; - // DocSection: import_rich_create_button_type - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task CreateButtonType() { - var client = MockClientFactory.CreateForSample(SampleFolder, "Empty.json"); + var client = MockClientFactory.CreateForSample(SampleFolder, "ImportedContentType.json"); - var response = await client.CreateContentTypeAsync(new ContentTypeCreateModel + // DocSection: import_rich_create_button_type + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + var response = (await client.CreateContentTypeAsync(new ContentTypeCreateModel { ExternalId = "button", Name = "Button", @@ -40,31 +40,33 @@ public async Task CreateButtonType() ExternalId = "button-link", }, ] - }); + })).EnsureSuccess(); + // EndDocSection } - // DocSection: import_rich_create_item - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task CreateItem() { - var client = MockClientFactory.CreateForSample(SampleFolder, "Empty.json"); + var client = MockClientFactory.CreateForSample(SampleFolder, "ImportedContentItem.json"); - await client.UpsertContentItemAsync(Reference.ByExternalId("simple-example"), new ContentItemUpsertModel + // DocSection: import_rich_create_item + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + (await client.UpsertContentItemAsync(Reference.ByExternalId("simple-example"), new ContentItemUpsertModel { Name = "Simple example", Type = Reference.ByExternalId("simple-rich-text"), - }); + })).EnsureSuccess(); + // EndDocSection } - // DocSection: import_rich_create_simple_type - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task CreateCreateSimpleType() { - var client = MockClientFactory.CreateForSample(SampleFolder, "Empty.json"); + var client = MockClientFactory.CreateForSample(SampleFolder, "ImportedContentType.json"); - var response = await client.CreateContentTypeAsync(new ContentTypeCreateModel + // DocSection: import_rich_create_simple_type + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + var response = (await client.CreateContentTypeAsync(new ContentTypeCreateModel { Name = "Simple Rich Text", Codename = "simple-rich-text", @@ -76,19 +78,20 @@ public async Task CreateCreateSimpleType() ExternalId = "rich-text", }, ] - }); + })).EnsureSuccess(); + // EndDocSection } - // DocSection: import_rich_upsert_variant - // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net [Fact] public async Task UpsertVariant() { - var client = MockClientFactory.CreateForSample(SampleFolder, "Empty.json"); + var client = MockClientFactory.CreateForSample(SampleFolder, "ImportedLanguageVariant.json"); + // DocSection: import_rich_upsert_variant + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net var identifier = new LanguageVariantIdentifier(Reference.ByExternalId("123"), Reference.ByCodename("en-US")); - await client.UpsertLanguageVariantAsync(identifier, new LanguageVariantUpsertModel + (await client.UpsertLanguageVariantAsync(identifier, new LanguageVariantUpsertModel { Elements = [ @@ -111,6 +114,7 @@ public async Task UpsertVariant() ], }, ], - }); + })).EnsureSuccess(); + // EndDocSection } } diff --git a/src/management/Kontent.Ai.Management.Tests/CodeSamples/README.md b/src/management/Kontent.Ai.Management.Tests/CodeSamples/README.md index f3577d658..0626472f3 100644 --- a/src/management/Kontent.Ai.Management.Tests/CodeSamples/README.md +++ b/src/management/Kontent.Ai.Management.Tests/CodeSamples/README.md @@ -1,5 +1,43 @@ -# Info +# Code samples -This folder is intended for storing code samples, to ensure they are compilable. +The samples in this folder are tests, so they are guaranteed to compile and to run against the SDK. They +are also the source for the published documentation samples in +[Kontent-ai-Learn/kontent-ai-learn-code-samples](https://github.com/Kontent-ai-Learn/kontent-ai-learn-code-samples/tree/master/net). +After merging a change here, mirror it there. -After merging a PR with updated or new code samples here, make sure to also update them in the designated repository => https://github.com/KenticoDocs/kontent-docs-samples/tree/master/net +## Sections + +A marked section is what gets published, so it must contain the sample and nothing else. The pair opens +below the mock client and closes above the assertions — both of those are test scaffolding and belong +outside: + +```csharp +[Fact] +public async Task DeleteAsset() +{ + var client = MockClientFactory.CreateForSample(SampleFolder); // outside + + // DocSection: cm_api_v2_delete_asset + // Tip: Find more about .NET SDKs at https://kontent.ai/learn/net + var identifier = Reference.ById(Guid.Parse("fcbb12e6-66a3-4672-85d9-d502d16b8d9c")); + // var identifier = Reference.ByExternalId("which-brewing-fits-you"); + + await client.DeleteAssetAsync(identifier); + // EndDocSection + + Assert.NotNull(response); // outside +} +``` + +The published file wraps that body in the `using` and a real `ManagementClient` construction. + +Three rules, each enforced by `DocSectionMarkerTests`: + +- **Every section is closed.** An unclosed one runs to the next marker and publishes everything between, + including the next method's `[Fact]` and mock setup. +- **Every id is used once.** The id names the published file, so two sections cannot share one. It is a + join key with that repository — check there before renaming, and reuse an existing id rather than + inventing a variant. +- **No scaffolding inside a section.** No `MockClientFactory`, `[Fact]`, `Assert.*` or + `Record.ExceptionAsync`. Assert *after* the closing marker; where a sample needs to show that a call + succeeded, `EnsureSuccess()` is both a real assertion and idiomatic sample code, so it can stay inside. diff --git a/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/Asset.json b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/Asset.json index 21131ec82..ee9b2815e 100644 --- a/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/Asset.json +++ b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/Asset.json @@ -1,27 +1,37 @@ { - "descriptions": [ + "elements": [ + { + "element": { "id": "7ef2ebb4-c480-42b7-ba35-a3078d6cce3f" }, + "value": [ + { "id": "4a4f8cb0-e7fe-40ad-9943-66f395e58571" }, + { "id": "96e493ab-45c4-4505-a3d0-b46192dd179e" } + ] + }, { - "language": { - "id": "00000000-0000-0000-0000-000000000000" - }, - "description": "The asset's alt text for the default language." + "element": { "id": "70dfa72d-4599-40cb-aa27-7597470d5e2e" }, + "value": [ + { "id": "16d27bf1-e0f4-8646-0e54-1b71efc6947f" } + ] } ], - "external_id": "custom-asset-identifier", - "file_name": "file_name.png", + "id": "222310d5-a416-4fc1-a4c5-b8c77062987d", + "codename": "chemex_paper_filters", + "file_name": "chemex-filters.jpg", + "title": "Chemex Paper Filters", + "size": 36927, + "type": "image/jpeg", + "url": "https://assets-us-01.kc-usercontent.com/cec32064-07dd-00ff-2101-5bde13c9e30c/222310d5-a416-4fc1-a4c5-b8c77062987d/chemex-filters.jpg", + "image_width": 1080, + "image_height": 1080, "file_reference": { - "id": "806ec84e-7c71-4856-9519-ee3dd3558583", + "id": "222310d5-a416-4fc1-a4c5-b8c77062987d", "type": "internal" }, - "folder": { - "id": "8fe4ff47-0ca8-449d-bc63-c280efee44ea" - }, - "id": "fcbb12e6-66a3-4672-85d9-d502d16b8d9c", - "image_height": 548, - "image_width": 1280, - "last_modified": "2019-09-12T08:29:36.1645977Z", - "size": 148636, - "title": "Makes the asset easier to find when you need it", - "type": "image/png", - "url": "https://assets-us-01.kc-usercontent.com/8d20758c-d74c-4f59-ae04-ee928c0816b7/adf26cd2-1acb-403f-9d1e-6d04e46c39f1/file_name.png" -} \ No newline at end of file + "descriptions": [ + { "language": { "id": "00000000-0000-0000-0000-000000000000" }, "description": "Chemex Paper Filters" }, + { "language": { "id": "d1f95fde-af02-b3b5-bd9e-f232311ccab8" }, "description": "Filtros de papel Chemex" }, + { "language": { "id": "228f96a1-05ba-4dd6-a1ac-d0fe4ef25470" }, "description": null }, + { "language": { "id": "be484cf2-67f6-410e-8620-f07bf264debc" }, "description": null } + ], + "last_modified": "2026-05-21T12:33:03.9926347Z" +} diff --git a/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/ContentItem.json b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/ContentItem.json index 0fa3ee6de..2f545515f 100644 --- a/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/ContentItem.json +++ b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/ContentItem.json @@ -1,14 +1,17 @@ { - "id": "335d17ac-b6ba-4c6a-ae31-23c1193215cb", - "name": "My article", - "codename": "my_article", + "id": "6b0a7992-e4a3-490f-a159-599846e918e6", + "name": "Home coffee mug2", + "codename": "big_mug", "type": { - "id": "d89b6348-7cdc-444a-8e1e-adacb564f2a2" + "id": "070917d4-4e14-499f-9a8c-492a973ba892" }, "collection": { - "id": "00000000-0000-0000-0000-000000000000" + "id": "df334bbb-6540-41cc-9aad-ea7114fb47ee" }, + "spaces": [ + { "id": "ece703a9-900e-4781-9d21-bdfbf9762751" }, + { "id": "f87dadc0-a0ac-4d3f-bdfd-6dc1df575ab5" } + ], "sitemap_locations": [], - "external_id": "custom-identifier-for-my-article", - "last_modified": "2019-04-04T13:45:30.7692802Z" + "last_modified": "2021-05-28T11:54:49.8609984Z" } \ No newline at end of file diff --git a/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/ImportedAsset.json b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/ImportedAsset.json new file mode 100644 index 000000000..ee9b2815e --- /dev/null +++ b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/ImportedAsset.json @@ -0,0 +1,37 @@ +{ + "elements": [ + { + "element": { "id": "7ef2ebb4-c480-42b7-ba35-a3078d6cce3f" }, + "value": [ + { "id": "4a4f8cb0-e7fe-40ad-9943-66f395e58571" }, + { "id": "96e493ab-45c4-4505-a3d0-b46192dd179e" } + ] + }, + { + "element": { "id": "70dfa72d-4599-40cb-aa27-7597470d5e2e" }, + "value": [ + { "id": "16d27bf1-e0f4-8646-0e54-1b71efc6947f" } + ] + } + ], + "id": "222310d5-a416-4fc1-a4c5-b8c77062987d", + "codename": "chemex_paper_filters", + "file_name": "chemex-filters.jpg", + "title": "Chemex Paper Filters", + "size": 36927, + "type": "image/jpeg", + "url": "https://assets-us-01.kc-usercontent.com/cec32064-07dd-00ff-2101-5bde13c9e30c/222310d5-a416-4fc1-a4c5-b8c77062987d/chemex-filters.jpg", + "image_width": 1080, + "image_height": 1080, + "file_reference": { + "id": "222310d5-a416-4fc1-a4c5-b8c77062987d", + "type": "internal" + }, + "descriptions": [ + { "language": { "id": "00000000-0000-0000-0000-000000000000" }, "description": "Chemex Paper Filters" }, + { "language": { "id": "d1f95fde-af02-b3b5-bd9e-f232311ccab8" }, "description": "Filtros de papel Chemex" }, + { "language": { "id": "228f96a1-05ba-4dd6-a1ac-d0fe4ef25470" }, "description": null }, + { "language": { "id": "be484cf2-67f6-410e-8620-f07bf264debc" }, "description": null } + ], + "last_modified": "2026-05-21T12:33:03.9926347Z" +} diff --git a/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/ImportedContentItem.json b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/ImportedContentItem.json new file mode 100644 index 000000000..2f545515f --- /dev/null +++ b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/ImportedContentItem.json @@ -0,0 +1,17 @@ +{ + "id": "6b0a7992-e4a3-490f-a159-599846e918e6", + "name": "Home coffee mug2", + "codename": "big_mug", + "type": { + "id": "070917d4-4e14-499f-9a8c-492a973ba892" + }, + "collection": { + "id": "df334bbb-6540-41cc-9aad-ea7114fb47ee" + }, + "spaces": [ + { "id": "ece703a9-900e-4781-9d21-bdfbf9762751" }, + { "id": "f87dadc0-a0ac-4d3f-bdfd-6dc1df575ab5" } + ], + "sitemap_locations": [], + "last_modified": "2021-05-28T11:54:49.8609984Z" +} \ No newline at end of file diff --git a/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/ImportedContentType.json b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/ImportedContentType.json new file mode 100644 index 000000000..c5d071d52 --- /dev/null +++ b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/ImportedContentType.json @@ -0,0 +1,285 @@ +{ + "id": "ba5cd79d-6d4b-5bed-a681-6aa3a366c8f7", + "codename": "article", + "last_modified": "2021-07-26T14:58:19.1748311Z", + "external_id": "b7aa4a53-d9b1-48cf-b7a6-ed0b182c4b89", + "name": "Article", + "content_groups": [], + "elements": [ + { + "guidelines": "

Keep Guidelines where the creative process happens.

These are sample guidelines that you can place for the whole content item. It’s a place where you can include your content brief, voice and tone recommendations or the URL to a wireframe, so the author will have all the relevant instructions at hand before writing a single line.

Besides overview guidelines, you can include instructions for each particular content element, as you will see below.

Guidelines sample

All articles will be displayed in the same section of the web site. Stick to an informal tone of voice when writing articles.

Use the following keywords: grinders, dripper, coffee.

", + "type": "guidelines", + "external_id": "2a3a744e-0529-dd13-793c-9e668133d48b", + "id": "f9f65756-5ed8-55ed-9d2a-43f9fe7974cd", + "codename": "n2a3a744e_0529_dd13_793c_9e668133d48b" + }, + { + "maximum_text_length": null, + "name": "Title", + "guidelines": "The title should fit within 60 characters.Our voice and tone recommendations: — avoid coffee jargon.", + "is_required": false, + "type": "text", + "external_id": "85d5efc6-f47e-2fde-a6f5-0950fe89ecd1", + "id": "ba7c8840-bcbc-5e3b-b292-24d0a60f3977", + "codename": "title", + "validation_regex": null, + "is_non_localizable": true, + "default": { + "global": { + "value": "This is the default value of the element." + } + } + }, + { + "source_url": "https://example.com", + "json_parameters": null, + "allowed_elements": [], + "name": "SelectedForm", + "guidelines": null, + "is_required": false, + "type": "custom", + "id": "47bf7d6d-285d-4ed0-9919-1d1a98b43acd", + "codename": "selectedform", + "is_non_localizable": false + }, + { + "name": "Rating", + "guidelines": null, + "is_required": false, + "type": "number", + "id": "773940f4-9e67-4a26-a93f-67e55fd7d837", + "codename": "rating", + "is_non_localizable": false, + "default": { + "global": { + "value": "10" + } + } + }, + { + "mode": "multiple", + "options": [ + { + "id": "00c0f86a-7c51-4e60-abeb-a150e9092e53", + "codename": "paid", + "name": "Paid" + }, + { + "id": "8972dc90-ae2e-416e-995d-95df6c77e3b2", + "codename": "featured", + "name": "Featured" + } + ], + "name": "Options", + "guidelines": null, + "is_required": false, + "type": "multiple_choice", + "id": "53a25074-b136-4a1f-a16d-3c130f696c66", + "codename": "options", + "is_non_localizable": false, + "default": { + "global": { + "value": [ + { "id": "8972dc90-ae2e-416e-995d-95df6c77e3b2" } + ] + } + } + }, + { + "asset_count_limit": null, + "maximum_file_size": null, + "allowed_file_types": "adjustable", + "image_width_limit": null, + "image_height_limit": null, + "name": "Teaser image", + "guidelines": "Upload an image at a resolution of at least 600 × 1200 px.", + "is_required": false, + "type": "asset", + "external_id": "62eb9881-e222-6b81-91d2-fdf052726414", + "id": "9c6a4fbc-3f73-585f-9521-8d57636adf56", + "codename": "teaser_image", + "is_non_localizable": false, + "default": { + "global": { + "value": [ + { "id": "1592be38-2db9-4037-a756-49204b736442" } + ] + } + } + }, + { + "name": "Post date", + "guidelines": "Provide a date that will appear on the live site as the date this article was posted live. This date will also influence the order of the articles. ", + "is_required": false, + "type": "date_time", + "external_id": "4ae5f7a9-fe1f-1e8c-bfec-d321455139c4", + "id": "0827e079-3754-5a1d-9381-8ff695a5bbf7", + "codename": "post_date", + "is_non_localizable": false, + "default": { + "global": { + "value": "2022-01-01T00:00:00.000Z" + } + } + }, + { + "maximum_text_length": null, + "name": "Summary", + "guidelines": "Provide a short summary of the text. It should be catchy and make the visitor want to read the whole article.The summary should fit within 160 characters.", + "is_required": false, + "type": "text", + "external_id": "90550cbe-7bff-40a9-2947-9c81489fe562", + "id": "15517aa3-da8a-5551-a4d4-555461fd5226", + "codename": "summary", + "validation_regex": { + "is_active": false, + "regex": "^[a-zA-Z.,?!\\s]{1,160}$", + "flags": null, + "validation_message": "Your text does not match the the given regex" + }, + "is_non_localizable": true + }, + { + "maximum_text_length": null, + "maximum_image_size": null, + "allowed_content_types": [], + "image_width_limit": null, + "image_height_limit": null, + "allowed_image_types": "any", + "allowed_blocks": [], + "allowed_formatting": [], + "allowed_text_blocks": [], + "allowed_table_blocks": [ + "text" + ], + "allowed_table_formatting": [], + "allowed_table_text_blocks": [ + "paragraph" + ], + "name": "Body Copy", + "guidelines": "Keep the article structured with concise paragraphs complemented with headlines that will help the reader navigate through the article's content.Preferred glossary terms — coffee, brewing, grinder, drip, roast, filter.", + "is_required": false, + "type": "rich_text", + "external_id": "108ed7c0-fc8c-c0ec-d0b5-5a8071408b54", + "id": "55a88ab3-4009-5bf9-a590-f32162f09b92", + "codename": "body_copy", + "is_non_localizable": false + }, + { + "item_count_limit": null, + "allowed_content_types": [], + "name": "Related articles", + "guidelines": "Provide articles with related topics. ", + "is_required": true, + "type": "modular_content", + "external_id": "ee7c3687-b469-6c56-3ac6-c8dfdc8b58b5", + "id": "77108990-3c30-5ffb-8dcd-8eb85fc52cb1", + "codename": "related_articles", + "is_non_localizable": false, + "default": { + "global": { + "value": [ + { "id": "3ab14319-bf46-464a-8635-301011d8702a" } + ] + } + } + }, + { + "maximum_text_length": null, + "name": "Meta keywords", + "guidelines": "Enter tags separated with a comma. Example: coffee, \"coffee roast”, grinder", + "is_required": false, + "type": "text", + "external_id": "5efb2425-5987-a4a6-a2d3-b14712b56e73", + "id": "0ee20a72-0aaa-521f-8801-df3d9293b7dd", + "codename": "meta_keywords", + "validation_regex": { + "is_active": true, + "regex": "[a-zA-Z][a-zA-Z0-9]*,\\s*", + "flags": null, + "validation_message": "Your text does not match the the given regex" + }, + "is_non_localizable": false + }, + { + "guidelines": "Provide all personas for which this article is relevant.", + "taxonomy_group": { + "id": "7fad4cb0-c96c-5bce-a6c4-ec02a0280632" + }, + "is_required": false, + "term_count_limit": null, + "type": "taxonomy", + "external_id": "0a16b642-ac3e-584d-a45a-ba354a30b2bd", + "id": "c1dc36b5-558d-55a2-8f31-787430a68e4d", + "codename": "personas", + "is_non_localizable": false, + "default": { + "global": { + "value": [ + { "id": "6e8b18d5-c5e3-5fc1-9014-44c18ef5f5d8" } + ] + } + } + }, + { + "maximum_text_length": null, + "name": "Meta description", + "guidelines": "Sum up the blog for SEO purposes. Limit for the meta description is 160 characters.", + "is_required": false, + "type": "text", + "external_id": "b9dc537c-2518-e4f5-8325-ce4fce26171e", + "id": "7df0048f-eaaf-50f8-85cf-fa0fc0d6d815", + "codename": "meta_description", + "is_non_localizable": false + }, + { + "depends_on": { + "snippet": { + "id": "5482e7b6-9c79-5e81-8c4b-90e172e7ab48" + }, + "element": { + "id": "f85f9ef6-ced1-5f04-9d92-163349d50e36" + } + }, + "name": "URL pattern", + "guidelines": "Provide a SEO-friendly URL.", + "is_required": false, + "type": "url_slug", + "external_id": "f2ff5e3f-a9ca-4604-58b0-34a2ad6a7cf1", + "id": "1f37e15b-27a0-5f48-b314-03b401c19cee", + "codename": "url_pattern", + "validation_regex":{ + "is_active": true, + "regex": "^[^\\s\\.\\s]*$", + "flags": null, + "validation_message": "Your text does not match the the given regex" + }, + "is_non_localizable": false + }, + { + "snippet": { + "id": "5482e7b6-9c79-5e81-8c4b-90e172e7ab48" + }, + "type": "snippet", + "external_id": "08c3994a-02f2-1278-7bed-e8fd9d26f3a4", + "id": "328810f4-7209-504e-b676-b4d48d11a6fb", + "codename": "metadata", + "is_non_localizable": false + }, + { + "item_count_limit": { + "value": 2, + "condition": "at_least" + }, + "allowed_content_types": [], + "name": "Menu", + "guidelines": "Menu guidelines", + "is_required": true, + "type": "subpages", + "external_id": "ee7c3687-b469-6c56-3ac6-c8dfdc8b58b5", + "id": "77108990-3c30-5ffb-8dcd-8eb85fc52cb1", + "codename": "related_articles", + "is_non_localizable": false + } + ] +} \ No newline at end of file diff --git a/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/ImportedFile.json b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/ImportedFile.json new file mode 100644 index 000000000..4211b0bb7 --- /dev/null +++ b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/ImportedFile.json @@ -0,0 +1,4 @@ +{ + "id": "c0ecaaa0-264c-49c6-9eee-02394633d9e3", + "type": "internal" +} \ No newline at end of file diff --git a/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/ImportedLanguageVariant.json b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/ImportedLanguageVariant.json new file mode 100644 index 000000000..f7ca5fc5e --- /dev/null +++ b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/ImportedLanguageVariant.json @@ -0,0 +1,192 @@ +{ + "elements": [ + { + "element": { + "id": "ba7c8840-bcbc-5e3b-b292-24d0a60f3977" + }, + "value": "On Roasts" + }, + { + "searchable_value": "Almighty form!", + "element": { + "id": "47bf7d6d-285d-4ed0-9919-1d1a98b43acd" + }, + "value": "{\"formId\": 42}" + }, + { + "element": { + "id": "773940f4-9e67-4a26-a93f-67e55fd7d837" + }, + "value": 3.14 + }, + { + "element": { + "id": "53a25074-b136-4a1f-a16d-3c130f696c66" + }, + "value": [ + { + "id": "00c0f86a-7c51-4e60-abeb-a150e9092e53" + }, + { + "id": "8972dc90-ae2e-416e-995d-95df6c77e3b2" + } + ] + }, + { + "element": { + "id": "9c6a4fbc-3f73-585f-9521-8d57636adf56" + }, + "value": [ + { + "renditions": [], + "id": "5c08a538-5b58-44eb-81ef-43fb37eeb815" + }, + { + "renditions": [ + { + "id": "043d8f8b-22cb-4322-a1de-8a96c57548a3" + }, + { + "id": "7538b9b1-bb5f-493e-b9ab-24578e2a55f5" + } + ], + "id": "39c947ab-78ee-4de0-9bbd-8b79008111cc" + } + ] + }, + { + "element": { + "id": "0827e079-3754-5a1d-9381-8ff695a5bbf7" + }, + "value": "2017-07-04T00:00:00Z", + "display_timezone": "Europe/Prague" + }, + { + "element": { + "id": "15517aa3-da8a-5551-a4d4-555461fd5226" + }, + "value": "Summary" + }, + { + "components": [ + { + "id": "46c05bd9-d418-4507-836c-9accc5a39db3", + "type": { + "id": "17ff8a28-ebe6-5c9d-95ea-18fe1ff86d2d" + }, + "elements": [ + { + "element": { + "id": "f09fb430-2a58-59cf-be03-621e1c367501" + }, + "value": "https://twitter.com/ChrastinaOndrej/status/1417105245935706123" + }, + { + "element": { + "id": "05017deb-18b2-5094-b367-e7f8796dd1b8" + }, + "value": [ + { + "id": "061e69f7-0965-5e37-97bc-29963cfaebe8" + } + ] + }, + { + "element": { + "id": "19e38194-a3a8-5d17-abed-9b70d7a5fd25" + }, + "value": [ + { + "id": "dd78b09e-4337-599c-9701-20a0a165c63b" + } + ] + } + ] + } + ], + "element": { + "id": "55a88ab3-4009-5bf9-a590-f32162f09b92" + }, + "value": "

Light Roasts

Usually roasted for 6 - 8 minutes or simply until achieving a light brown color.This method is used for milder coffee varieties and for coffee tasting.This type of roasting allows the natural characteristics of each coffee to show.The aroma of coffees produced from light roasts is usually more intense.The cup itself is more acidic and the concentration of caffeine is higher.

" + }, + { + "element": { + "id": "77108990-3c30-5ffb-8dcd-8eb85fc52cb1" + }, + "value": [ + { + "id": "4b628214-e4fe-4fe0-b1ff-955df33e1515" + } + ] + }, + { + "element": { + "id": "0ee20a72-0aaa-521f-8801-df3d9293b7dd" + }, + "value": "MetaKeywords" + }, + { + "element": { + "id": "c1dc36b5-558d-55a2-8f31-787430a68e4d" + }, + "value": [ + { + "id": "6e8b18d5-c5e3-5fc1-9014-44c18ef5f5d8" + } + ] + }, + { + "element": { + "id": "7df0048f-eaaf-50f8-85cf-fa0fc0d6d815" + }, + "value": "MetaDescription" + }, + { + "mode": "custom", + "element": { + "id": "1f37e15b-27a0-5f48-b314-03b401c19cee" + }, + "value": "on-roasts" + }, + { + "element": { + "id": "a29858ff-fa9f-5841-a682-d7fb6cc6effe" + }, + "value": [ + { + "id": "4b628214-e4fe-4fe0-b1ff-955df33e1515" + } + ] + } + ], + "schedule": { + "publish_time": "2024-03-31T08:00:00", + "publish_display_timezone": "Europe/Prague", + "unpublish_time": "2024-04-30T08:00:00", + "unpublish_display_timezone": "Europe/Prague" + }, + "workflow": { + "workflow_identifier": { + "id": "00000000-0000-0000-0000-000000000000" + }, + "step_identifier": { + "id": "eee6db3b-545a-4785-8e86-e3772c8756f9" + } + }, + "due_date": { + "value": "2092-01-07T06:04:00.7069564Z" + }, + "contributors": [ + { + "id": "4b628214-e4fe-4fe0-b1ff-955df33e1515" + } + ], + "note": "Just a note", + "item": { + "id": "4b628214-e4fe-4fe0-b1ff-955df33e1515" + }, + "language": { + "id": "78dbefe8-831b-457e-9352-f4c4eacd5024" + }, + "last_modified": "2021-11-06T13:57:26.7069564Z" +} \ No newline at end of file diff --git a/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/ImportedSnippet.json b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/ImportedSnippet.json new file mode 100644 index 000000000..c746ba97a --- /dev/null +++ b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/ImportedSnippet.json @@ -0,0 +1,185 @@ +{ + "id": "5482e7b6-9c79-5e81-8c4b-90e172e7ab48", + "codename": "metadata", + "last_modified": "2021-06-02T11:08:26.6464271Z", + "external_id": "baf884be-531f-441f-ae88-64205efdd0f6", + "name": "Metadata", + "elements": [ + { + "guidelines": "

Keep Guidelines where the creative process happens.

These are sample guidelines that you can place for the whole content item. It's a place where you can include your content brief, voice and tone recommendations or the URL to a wireframe, so the author will have all the relevant instructions at hand before writing a single line.

Besides overview guidelines, you can include instructions for each particular content element, as you will see below.

Guidelines sample

All articles will be displayed in the same section of the web site. Stick to an informal tone of voice when writing articles.

Use the following keywords: grinders, dripper, coffee.

", + "type": "guidelines", + "external_id": "2a3a744e-0529-dd13-793c-9e668133d48b", + "id": "f9f65756-5ed8-55ed-9d2a-43f9fe7974cd", + "codename": "n2a3a744e_0529_dd13_793c_9e668133d48b" + }, + { + "maximum_text_length": null, + "name": "Title", + "guidelines": "The title should fit within 60 characters.Our voice and tone recommendations: - avoid coffee jargon.", + "is_required": false, + "type": "text", + "external_id": "85d5efc6-f47e-2fde-a6f5-0950fe89ecd1", + "id": "ba7c8840-bcbc-5e3b-b292-24d0a60f3977", + "codename": "title", + "validation_regex": { + "is_active": false, + "regex": "^[a-zA-Z.,?!\\s]{1,160}$", + "flags": null, + "validation_message": "Your text does not match the the given regex" + }, + "is_non_localizable": false + }, + { + "source_url": "https://example.com", + "json_parameters": null, + "allowed_elements": [], + "name": "SelectedForm", + "guidelines": null, + "is_required": false, + "type": "custom", + "id": "47bf7d6d-285d-4ed0-9919-1d1a98b43acd", + "codename": "selectedform", + "is_non_localizable": false + }, + { + "name": "Rating", + "guidelines": null, + "is_required": false, + "type": "number", + "id": "773940f4-9e67-4a26-a93f-67e55fd7d837", + "codename": "rating", + "is_non_localizable": false + }, + { + "mode": "multiple", + "options": [ + { + "id": "00c0f86a-7c51-4e60-abeb-a150e9092e53", + "codename": "paid", + "name": "Paid" + }, + { + "id": "8972dc90-ae2e-416e-995d-95df6c77e3b2", + "codename": "featured", + "name": "Featured" + } + ], + "name": "Options", + "guidelines": null, + "is_required": false, + "type": "multiple_choice", + "id": "53a25074-b136-4a1f-a16d-3c130f696c66", + "codename": "options", + "is_non_localizable": true + }, + { + "asset_count_limit": null, + "maximum_file_size": null, + "allowed_file_types": "adjustable", + "image_width_limit": null, + "image_height_limit": null, + "name": "Teaser image", + "guidelines": "Upload an image at a resolution of at least 600 x 1200 px.", + "is_required": false, + "type": "asset", + "external_id": "62eb9881-e222-6b81-91d2-fdf052726414", + "id": "9c6a4fbc-3f73-585f-9521-8d57636adf56", + "codename": "teaser_image", + "is_non_localizable": false + }, + { + "name": "Post date", + "guidelines": "Provide a date that will appear on the live site as the date this article was posted live. This date will also influence the order of the articles. ", + "is_required": false, + "type": "date_time", + "external_id": "4ae5f7a9-fe1f-1e8c-bfec-d321455139c4", + "id": "0827e079-3754-5a1d-9381-8ff695a5bbf7", + "codename": "post_date", + "is_non_localizable": true + }, + { + "maximum_text_length": null, + "name": "Summary", + "guidelines": "Provide a short summary of the text. It should be catchy and make the visitor want to read the whole article.The summary should fit within 160 characters.", + "is_required": false, + "type": "text", + "external_id": "90550cbe-7bff-40a9-2947-9c81489fe562", + "id": "15517aa3-da8a-5551-a4d4-555461fd5226", + "codename": "summary", + "is_non_localizable": true + }, + { + "maximum_text_length": null, + "maximum_image_size": null, + "allowed_content_types": [], + "image_width_limit": null, + "image_height_limit": null, + "allowed_image_types": "any", + "allowed_blocks": [], + "allowed_formatting": [], + "allowed_text_blocks": [], + "allowed_table_blocks": [ + "text" + ], + "allowed_table_formatting": [], + "allowed_table_text_blocks": [ + "paragraph" + ], + "name": "Body Copy", + "guidelines": "Keep the article structured with concise paragraphs complemented with headlines that will help the reader navigate through the article's content.Preferred glossary terms - coffee, brewing, grinder, drip, roast, filter.", + "is_required": false, + "type": "rich_text", + "external_id": "108ed7c0-fc8c-c0ec-d0b5-5a8071408b54", + "id": "55a88ab3-4009-5bf9-a590-f32162f09b92", + "codename": "body_copy", + "is_non_localizable": false + }, + { + "item_count_limit": null, + "allowed_content_types": [], + "name": "Related articles", + "guidelines": "Provide articles with related topics. ", + "is_required": true, + "type": "modular_content", + "external_id": "ee7c3687-b469-6c56-3ac6-c8dfdc8b58b5", + "id": "77108990-3c30-5ffb-8dcd-8eb85fc52cb1", + "codename": "related_articles", + "is_non_localizable": false + }, + { + "maximum_text_length": null, + "name": "Meta keywords", + "guidelines": "Enter tags separated with a comma. Example: coffee, \"coffee roast\", grinder", + "is_required": false, + "type": "text", + "external_id": "5efb2425-5987-a4a6-a2d3-b14712b56e73", + "id": "0ee20a72-0aaa-521f-8801-df3d9293b7dd", + "codename": "meta_keywords", + "is_non_localizable": true + }, + { + "guidelines": "Provide all personas for which this article is relevant.", + "taxonomy_group": { + "id": "7fad4cb0-c96c-5bce-a6c4-ec02a0280632" + }, + "is_required": false, + "term_count_limit": null, + "type": "taxonomy", + "external_id": "0a16b642-ac3e-584d-a45a-ba354a30b2bd", + "id": "c1dc36b5-558d-55a2-8f31-787430a68e4d", + "codename": "personas", + "is_non_localizable": true + }, + { + "maximum_text_length": null, + "name": "Meta description", + "guidelines": "Sum up the blog for SEO purposes. Limit for the meta description is 160 characters.", + "is_required": false, + "type": "text", + "external_id": "b9dc537c-2518-e4f5-8325-ce4fce26171e", + "id": "7df0048f-eaaf-50f8-85cf-fa0fc0d6d815", + "codename": "meta_description", + "is_non_localizable": true + } + ] +} \ No newline at end of file diff --git a/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/ImportedTaxonomyGroup.json b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/ImportedTaxonomyGroup.json new file mode 100644 index 000000000..fb990d321 --- /dev/null +++ b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/ImportedTaxonomyGroup.json @@ -0,0 +1,37 @@ +{ + "last_modified": "2021-06-02T11:08:26.2089083Z", + "id": "f6851a6e-a342-5253-8bc6-e8abc8f56b15", + "name": "Manufacturer", + "codename": "manufacturer", + "external_id": "4ce421e9-c403-eee8-fdc2-74f09392a749", + "terms": [ + { + "id": "d052a5a6-b8a5-52b2-b8e6-f0e073e5a943", + "name": "Aerobie", + "codename": "aerobie", + "external_id": "f04c8552-1b97-a49b-3944-79275622f471", + "terms": [] + }, + { + "id": "09060657-7288-5e14-806a-e3fd5548d2e5", + "name": "Chemex", + "codename": "chemex", + "external_id": "16d27bf1-e0f4-8646-0e54-1b71efc6947f", + "terms": [] + }, + { + "id": "33a832cf-ce27-54eb-8d84-e435a930fad3", + "name": "Espro", + "codename": "espro", + "external_id": "b378225f-6dfc-e261-3848-dd030a6d7883", + "terms": [] + }, + { + "id": "6b6aac7a-1528-58d5-9e5b-c49f6f217a84", + "name": "Hario", + "codename": "hario", + "external_id": "6fde9724-5b72-8bc9-6da0-4f0573a54532", + "terms": [] + } + ] +} \ No newline at end of file diff --git a/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/ImportedValidationReport.json b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/ImportedValidationReport.json new file mode 100644 index 000000000..a372f0186 --- /dev/null +++ b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/ImportedValidationReport.json @@ -0,0 +1,294 @@ +{ + "project": { + "id": "a9931a80-9af4-010b-0000-ecb1273cf1b8", + "name": "Sample project", + "environment": "Production" + }, + "variant_issues": [ + { + "item": { + "id": "aaaa8357-d1b4-4a51-bcaa-10864d80babc", + "name": "Donate with us", + "codename": "donate_with_us" + }, + "language": { + "id": "00000000-0000-0000-0000-000000000000", + "name": "Default project language", + "codename": "en-US" + }, + "issues": [ + { + "element": { + "id": "77108990-3c30-5ffb-8dcd-8eb85fc52cb1", + "name": "Related articles", + "codename": "related_articles" + }, + "messages": [ + "Element 'Related articles' is required but has no value." + ] + } + ] + }, + { + "item": { + "id": "8384eee7-e882-4ea8-b480-59138d66e468", + "name": "Example of content (open me)", + "codename": "example_of_content__open_me_" + }, + "language": { + "id": "00000000-0000-0000-0000-000000000000", + "name": "Default project language", + "codename": "en-US" + }, + "issues": [ + { + "element": { + "id": "77108990-3c30-5ffb-8dcd-8eb85fc52cb1", + "name": "Related articles", + "codename": "related_articles" + }, + "messages": [ + "Element 'Related articles' is required but has no value." + ] + } + ] + }, + { + "item": { + "id": "1ff7210f-bff3-4d20-9e08-70e33d1c72dd", + "name": "Origins of Arabica Bourbon", + "codename": "origins_of_arabica_bourbon" + }, + "language": { + "id": "00000000-0000-0000-0000-000000000000", + "name": "Default project language", + "codename": "en-US" + }, + "issues": [ + { + "element": { + "id": "77108990-3c30-5ffb-8dcd-8eb85fc52cb1", + "name": "Related articles", + "codename": "related_articles" + }, + "messages": [ + "Element 'Related articles' is required but has no value." + ] + } + ] + }, + { + "item": { + "id": "deee0b3c-7b3c-4841-a603-5ada23f550fd", + "name": "Coffee Beverages Explained", + "codename": "coffee_beverages_explained" + }, + "language": { + "id": "00000000-0000-0000-0000-000000000000", + "name": "Default project language", + "codename": "en-US" + }, + "issues": [ + { + "element": { + "id": "77108990-3c30-5ffb-8dcd-8eb85fc52cb1", + "name": "Related articles", + "codename": "related_articles" + }, + "messages": [ + "Element 'Related articles' is required but has no value." + ] + } + ] + }, + { + "item": { + "id": "1037d403-caf0-4c37-b6b3-61a273769976", + "name": "Not Valid Article", + "codename": "not_valid_article" + }, + "language": { + "id": "00000000-0000-0000-0000-000000000000", + "name": "Default project language", + "codename": "en-US" + }, + "issues": [ + { + "element": { + "id": "77108990-3c30-5ffb-8dcd-8eb85fc52cb1", + "name": "Related articles", + "codename": "related_articles" + }, + "messages": [ + "Element 'Related articles' is required but has no value." + ] + } + ] + }, + { + "item": { + "id": "452f515f-0e1d-426f-8b20-874b41f7fac4", + "name": "Hooray!", + "codename": "hooray__452f515" + }, + "language": { + "id": "00000000-0000-0000-0000-000000000000", + "name": "Default project language", + "codename": "en-US" + }, + "issues": [ + { + "element": { + "id": "77108990-3c30-5ffb-8dcd-8eb85fc52cb1", + "name": "Related articles", + "codename": "related_articles" + }, + "messages": [ + "Element 'Related articles' is required but has no value." + ] + } + ] + }, + { + "item": { + "id": "667c819d-8a14-4f4b-a25a-9d9681db4729", + "name": "Article in the", + "codename": "article_in_the" + }, + "language": { + "id": "00000000-0000-0000-0000-000000000000", + "name": "Default project language", + "codename": "en-US" + }, + "issues": [ + { + "element": { + "id": "77108990-3c30-5ffb-8dcd-8eb85fc52cb1", + "name": "Related articles", + "codename": "related_articles" + }, + "messages": [ + "Element 'Related articles' is required but has no value." + ] + } + ] + }, + { + "item": { + "id": "aaaa8357-d1b4-4a51-bcaa-10864d80babc", + "name": "Donate with us", + "codename": "donate_with_us" + }, + "language": { + "id": "78dbefe8-831b-457e-9352-f4c4eacd5024", + "name": "Spanish (Spain)", + "codename": "es-ES" + }, + "issues": [ + { + "element": { + "id": "77108990-3c30-5ffb-8dcd-8eb85fc52cb1", + "name": "Related articles", + "codename": "related_articles" + }, + "messages": [ + "Element 'Related articles' is required but has no value." + ] + } + ] + }, + { + "item": { + "id": "8384eee7-e882-4ea8-b480-59138d66e468", + "name": "Example of content (open me)", + "codename": "example_of_content__open_me_" + }, + "language": { + "id": "78dbefe8-831b-457e-9352-f4c4eacd5024", + "name": "Spanish (Spain)", + "codename": "es-ES" + }, + "issues": [ + { + "element": { + "id": "77108990-3c30-5ffb-8dcd-8eb85fc52cb1", + "name": "Related articles", + "codename": "related_articles" + }, + "messages": [ + "Element 'Related articles' is required but has no value." + ] + } + ] + }, + { + "item": { + "id": "1ff7210f-bff3-4d20-9e08-70e33d1c72dd", + "name": "Origins of Arabica Bourbon", + "codename": "origins_of_arabica_bourbon" + }, + "language": { + "id": "78dbefe8-831b-457e-9352-f4c4eacd5024", + "name": "Spanish (Spain)", + "codename": "es-ES" + }, + "issues": [ + { + "element": { + "id": "77108990-3c30-5ffb-8dcd-8eb85fc52cb1", + "name": "Related articles", + "codename": "related_articles" + }, + "messages": [ + "Element 'Related articles' is required but has no value." + ] + } + ] + }, + { + "item": { + "id": "deee0b3c-7b3c-4841-a603-5ada23f550fd", + "name": "Coffee Beverages Explained", + "codename": "coffee_beverages_explained" + }, + "language": { + "id": "78dbefe8-831b-457e-9352-f4c4eacd5024", + "name": "Spanish (Spain)", + "codename": "es-ES" + }, + "issues": [ + { + "element": { + "id": "77108990-3c30-5ffb-8dcd-8eb85fc52cb1", + "name": "Related articles", + "codename": "related_articles" + }, + "messages": [ + "Element 'Related articles' is required but has no value." + ] + } + ] + } + ], + "type_issues": [ + { + "type": { + "id": "cb484d32-414d-4b76-bd69-5578cffd1571", + "name": "With deleted taxonomy", + "codename": "with_deleted_taxonomy" + }, + "issues": [ + { + "element": { + "id": "b4ac2640-eaca-43ea-874b-8bb257c9b6a9", + "name": "To delete", + "codename": "to_delete" + }, + "messages": [ + "Element 'To delete' contains references to non-existing taxonomy group with ID fc563f94-26a2-456f-967c-d130e68c07d8." + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/LanguageVariant.json b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/LanguageVariant.json index 93babb680..f7ca5fc5e 100644 --- a/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/LanguageVariant.json +++ b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/LanguageVariant.json @@ -2,26 +2,191 @@ "elements": [ { "element": { - "id": "c7c3b834-2222-5677-89c4-b46f04489109" + "id": "ba7c8840-bcbc-5e3b-b292-24d0a60f3977" }, - "value": "Text element value" + "value": "On Roasts" + }, + { + "searchable_value": "Almighty form!", + "element": { + "id": "47bf7d6d-285d-4ed0-9919-1d1a98b43acd" + }, + "value": "{\"formId\": 42}" + }, + { + "element": { + "id": "773940f4-9e67-4a26-a93f-67e55fd7d837" + }, + "value": 3.14 + }, + { + "element": { + "id": "53a25074-b136-4a1f-a16d-3c130f696c66" + }, + "value": [ + { + "id": "00c0f86a-7c51-4e60-abeb-a150e9092e53" + }, + { + "id": "8972dc90-ae2e-416e-995d-95df6c77e3b2" + } + ] + }, + { + "element": { + "id": "9c6a4fbc-3f73-585f-9521-8d57636adf56" + }, + "value": [ + { + "renditions": [], + "id": "5c08a538-5b58-44eb-81ef-43fb37eeb815" + }, + { + "renditions": [ + { + "id": "043d8f8b-22cb-4322-a1de-8a96c57548a3" + }, + { + "id": "7538b9b1-bb5f-493e-b9ab-24578e2a55f5" + } + ], + "id": "39c947ab-78ee-4de0-9bbd-8b79008111cc" + } + ] + }, + { + "element": { + "id": "0827e079-3754-5a1d-9381-8ff695a5bbf7" + }, + "value": "2017-07-04T00:00:00Z", + "display_timezone": "Europe/Prague" + }, + { + "element": { + "id": "15517aa3-da8a-5551-a4d4-555461fd5226" + }, + "value": "Summary" + }, + { + "components": [ + { + "id": "46c05bd9-d418-4507-836c-9accc5a39db3", + "type": { + "id": "17ff8a28-ebe6-5c9d-95ea-18fe1ff86d2d" + }, + "elements": [ + { + "element": { + "id": "f09fb430-2a58-59cf-be03-621e1c367501" + }, + "value": "https://twitter.com/ChrastinaOndrej/status/1417105245935706123" + }, + { + "element": { + "id": "05017deb-18b2-5094-b367-e7f8796dd1b8" + }, + "value": [ + { + "id": "061e69f7-0965-5e37-97bc-29963cfaebe8" + } + ] + }, + { + "element": { + "id": "19e38194-a3a8-5d17-abed-9b70d7a5fd25" + }, + "value": [ + { + "id": "dd78b09e-4337-599c-9701-20a0a165c63b" + } + ] + } + ] + } + ], + "element": { + "id": "55a88ab3-4009-5bf9-a590-f32162f09b92" + }, + "value": "

Light Roasts

Usually roasted for 6 - 8 minutes or simply until achieving a light brown color.This method is used for milder coffee varieties and for coffee tasting.This type of roasting allows the natural characteristics of each coffee to show.The aroma of coffees produced from light roasts is usually more intense.The cup itself is more acidic and the concentration of caffeine is higher.

" + }, + { + "element": { + "id": "77108990-3c30-5ffb-8dcd-8eb85fc52cb1" + }, + "value": [ + { + "id": "4b628214-e4fe-4fe0-b1ff-955df33e1515" + } + ] + }, + { + "element": { + "id": "0ee20a72-0aaa-521f-8801-df3d9293b7dd" + }, + "value": "MetaKeywords" + }, + { + "element": { + "id": "c1dc36b5-558d-55a2-8f31-787430a68e4d" + }, + "value": [ + { + "id": "6e8b18d5-c5e3-5fc1-9014-44c18ef5f5d8" + } + ] + }, + { + "element": { + "id": "7df0048f-eaaf-50f8-85cf-fa0fc0d6d815" + }, + "value": "MetaDescription" }, { "mode": "custom", "element": { - "id": "53a5eecb-f295-59b4-a07d-19655b6ad860" + "id": "1f37e15b-27a0-5f48-b314-03b401c19cee" }, - "value": "custom-url-slug-value" + "value": "on-roasts" + }, + { + "element": { + "id": "a29858ff-fa9f-5841-a682-d7fb6cc6effe" + }, + "value": [ + { + "id": "4b628214-e4fe-4fe0-b1ff-955df33e1515" + } + ] } ], - "workflow_step": { - "id": "dc87d7cf-424b-4b89-9519-c9f79a3458b7" + "schedule": { + "publish_time": "2024-03-31T08:00:00", + "publish_display_timezone": "Europe/Prague", + "unpublish_time": "2024-04-30T08:00:00", + "unpublish_display_timezone": "Europe/Prague" }, + "workflow": { + "workflow_identifier": { + "id": "00000000-0000-0000-0000-000000000000" + }, + "step_identifier": { + "id": "eee6db3b-545a-4785-8e86-e3772c8756f9" + } + }, + "due_date": { + "value": "2092-01-07T06:04:00.7069564Z" + }, + "contributors": [ + { + "id": "4b628214-e4fe-4fe0-b1ff-955df33e1515" + } + ], + "note": "Just a note", "item": { - "id": "82ef61f4-ccee-42ac-95e2-1a44beda9625" + "id": "4b628214-e4fe-4fe0-b1ff-955df33e1515" }, "language": { - "id": "00000000-0000-0000-0000-000000000000" + "id": "78dbefe8-831b-457e-9352-f4c4eacd5024" }, - "last_modified": "2020-02-27T19:08:25.404Z" + "last_modified": "2021-11-06T13:57:26.7069564Z" } \ No newline at end of file diff --git a/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/PostAssetResponse.json b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/PostAssetResponse.json index 3ba351b46..ee9b2815e 100644 --- a/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/PostAssetResponse.json +++ b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/PostAssetResponse.json @@ -1,27 +1,37 @@ { - "id": "fcbb12e6-66a3-4672-85d9-d502d16b8d9c", - "file_name": "file_name.jpeg", - "title": "Description of what the file contains", - "size": 148636, + "elements": [ + { + "element": { "id": "7ef2ebb4-c480-42b7-ba35-a3078d6cce3f" }, + "value": [ + { "id": "4a4f8cb0-e7fe-40ad-9943-66f395e58571" }, + { "id": "96e493ab-45c4-4505-a3d0-b46192dd179e" } + ] + }, + { + "element": { "id": "70dfa72d-4599-40cb-aa27-7597470d5e2e" }, + "value": [ + { "id": "16d27bf1-e0f4-8646-0e54-1b71efc6947f" } + ] + } + ], + "id": "222310d5-a416-4fc1-a4c5-b8c77062987d", + "codename": "chemex_paper_filters", + "file_name": "chemex-filters.jpg", + "title": "Chemex Paper Filters", + "size": 36927, "type": "image/jpeg", - "url": "https://assets-us-01.kc-usercontent.com/8d20758c-d74c-4f59-ae04-ee928c0816b7/adf26cd2-1acb-403f-9d1e-6d04e46c39f1/file_name.png", - "image_width": 1280, - "image_height": 548, + "url": "https://assets-us-01.kc-usercontent.com/cec32064-07dd-00ff-2101-5bde13c9e30c/222310d5-a416-4fc1-a4c5-b8c77062987d/chemex-filters.jpg", + "image_width": 1080, + "image_height": 1080, "file_reference": { - "id": "fcbb12e6-66a3-4672-85d9-d502d16b8d9c", + "id": "222310d5-a416-4fc1-a4c5-b8c77062987d", "type": "internal" }, - "folder": { - "id": "8fe4ff47-0ca8-449d-bc63-c280efee44ea" - }, "descriptions": [ - { - "language": { - "codename": "default" - }, - "description": "The asset's alt text in the default language describing what the file or image shows." - } + { "language": { "id": "00000000-0000-0000-0000-000000000000" }, "description": "Chemex Paper Filters" }, + { "language": { "id": "d1f95fde-af02-b3b5-bd9e-f232311ccab8" }, "description": "Filtros de papel Chemex" }, + { "language": { "id": "228f96a1-05ba-4dd6-a1ac-d0fe4ef25470" }, "description": null }, + { "language": { "id": "be484cf2-67f6-410e-8620-f07bf264debc" }, "description": null } ], - "external_id": "custom-asset-identifier", - "last_modified": "2017-09-12T08:29:36.1645977Z" -} \ No newline at end of file + "last_modified": "2026-05-21T12:33:03.9926347Z" +} diff --git a/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/PostItemResponse.json b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/PostItemResponse.json index e789bfce0..2f545515f 100644 --- a/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/PostItemResponse.json +++ b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/PostItemResponse.json @@ -1,14 +1,17 @@ { - "id": "335d17ac-b6ba-4c6a-ae31-23c1193215cb", - "name": "My article", - "codename": "my_article_2", + "id": "6b0a7992-e4a3-490f-a159-599846e918e6", + "name": "Home coffee mug2", + "codename": "big_mug", "type": { - "id": "b7aa4a53-d9b1-48cf-b7a6-ed0b182c4b89" + "id": "070917d4-4e14-499f-9a8c-492a973ba892" }, "collection": { - "id": "00000000-0000-0000-0000-000000000000" + "id": "df334bbb-6540-41cc-9aad-ea7114fb47ee" }, + "spaces": [ + { "id": "ece703a9-900e-4781-9d21-bdfbf9762751" }, + { "id": "f87dadc0-a0ac-4d3f-bdfd-6dc1df575ab5" } + ], "sitemap_locations": [], - "external_id": "my-1337-article", - "last_modified": "2020-04-04T13:45:30.7692802Z" + "last_modified": "2021-05-28T11:54:49.8609984Z" } \ No newline at end of file diff --git a/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/PostSnippetResponse.json b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/PostSnippetResponse.json index 05127341a..c746ba97a 100644 --- a/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/PostSnippetResponse.json +++ b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/PostSnippetResponse.json @@ -1,24 +1,185 @@ { - "id": "c295baa0-f910-499f-9ca2-523be657019d", - "codename": "my_metadata", + "id": "5482e7b6-9c79-5e81-8c4b-90e172e7ab48", + "codename": "metadata", + "last_modified": "2021-06-02T11:08:26.6464271Z", + "external_id": "baf884be-531f-441f-ae88-64205efdd0f6", "name": "Metadata", - "external_id": "my_metadata_elements", "elements": [ { - "name": "Meta title", - "guidelines": "Length: 30–60 characters", + "guidelines": "

Keep Guidelines where the creative process happens.

These are sample guidelines that you can place for the whole content item. It's a place where you can include your content brief, voice and tone recommendations or the URL to a wireframe, so the author will have all the relevant instructions at hand before writing a single line.

Besides overview guidelines, you can include instructions for each particular content element, as you will see below.

Guidelines sample

All articles will be displayed in the same section of the web site. Stick to an informal tone of voice when writing articles.

Use the following keywords: grinders, dripper, coffee.

", + "type": "guidelines", + "external_id": "2a3a744e-0529-dd13-793c-9e668133d48b", + "id": "f9f65756-5ed8-55ed-9d2a-43f9fe7974cd", + "codename": "n2a3a744e_0529_dd13_793c_9e668133d48b" + }, + { + "maximum_text_length": null, + "name": "Title", + "guidelines": "The title should fit within 60 characters.Our voice and tone recommendations: - avoid coffee jargon.", + "is_required": false, + "type": "text", + "external_id": "85d5efc6-f47e-2fde-a6f5-0950fe89ecd1", + "id": "ba7c8840-bcbc-5e3b-b292-24d0a60f3977", + "codename": "title", + "validation_regex": { + "is_active": false, + "regex": "^[a-zA-Z.,?!\\s]{1,160}$", + "flags": null, + "validation_message": "Your text does not match the the given regex" + }, + "is_non_localizable": false + }, + { + "source_url": "https://example.com", + "json_parameters": null, + "allowed_elements": [], + "name": "SelectedForm", + "guidelines": null, + "is_required": false, + "type": "custom", + "id": "47bf7d6d-285d-4ed0-9919-1d1a98b43acd", + "codename": "selectedform", + "is_non_localizable": false + }, + { + "name": "Rating", + "guidelines": null, + "is_required": false, + "type": "number", + "id": "773940f4-9e67-4a26-a93f-67e55fd7d837", + "codename": "rating", + "is_non_localizable": false + }, + { + "mode": "multiple", + "options": [ + { + "id": "00c0f86a-7c51-4e60-abeb-a150e9092e53", + "codename": "paid", + "name": "Paid" + }, + { + "id": "8972dc90-ae2e-416e-995d-95df6c77e3b2", + "codename": "featured", + "name": "Featured" + } + ], + "name": "Options", + "guidelines": null, + "is_required": false, + "type": "multiple_choice", + "id": "53a25074-b136-4a1f-a16d-3c130f696c66", + "codename": "options", + "is_non_localizable": true + }, + { + "asset_count_limit": null, + "maximum_file_size": null, + "allowed_file_types": "adjustable", + "image_width_limit": null, + "image_height_limit": null, + "name": "Teaser image", + "guidelines": "Upload an image at a resolution of at least 600 x 1200 px.", + "is_required": false, + "type": "asset", + "external_id": "62eb9881-e222-6b81-91d2-fdf052726414", + "id": "9c6a4fbc-3f73-585f-9521-8d57636adf56", + "codename": "teaser_image", + "is_non_localizable": false + }, + { + "name": "Post date", + "guidelines": "Provide a date that will appear on the live site as the date this article was posted live. This date will also influence the order of the articles. ", + "is_required": false, + "type": "date_time", + "external_id": "4ae5f7a9-fe1f-1e8c-bfec-d321455139c4", + "id": "0827e079-3754-5a1d-9381-8ff695a5bbf7", + "codename": "post_date", + "is_non_localizable": true + }, + { + "maximum_text_length": null, + "name": "Summary", + "guidelines": "Provide a short summary of the text. It should be catchy and make the visitor want to read the whole article.The summary should fit within 160 characters.", + "is_required": false, + "type": "text", + "external_id": "90550cbe-7bff-40a9-2947-9c81489fe562", + "id": "15517aa3-da8a-5551-a4d4-555461fd5226", + "codename": "summary", + "is_non_localizable": true + }, + { + "maximum_text_length": null, + "maximum_image_size": null, + "allowed_content_types": [], + "image_width_limit": null, + "image_height_limit": null, + "allowed_image_types": "any", + "allowed_blocks": [], + "allowed_formatting": [], + "allowed_text_blocks": [], + "allowed_table_blocks": [ + "text" + ], + "allowed_table_formatting": [], + "allowed_table_text_blocks": [ + "paragraph" + ], + "name": "Body Copy", + "guidelines": "Keep the article structured with concise paragraphs complemented with headlines that will help the reader navigate through the article's content.Preferred glossary terms - coffee, brewing, grinder, drip, roast, filter.", + "is_required": false, + "type": "rich_text", + "external_id": "108ed7c0-fc8c-c0ec-d0b5-5a8071408b54", + "id": "55a88ab3-4009-5bf9-a590-f32162f09b92", + "codename": "body_copy", + "is_non_localizable": false + }, + { + "item_count_limit": null, + "allowed_content_types": [], + "name": "Related articles", + "guidelines": "Provide articles with related topics. ", + "is_required": true, + "type": "modular_content", + "external_id": "ee7c3687-b469-6c56-3ac6-c8dfdc8b58b5", + "id": "77108990-3c30-5ffb-8dcd-8eb85fc52cb1", + "codename": "related_articles", + "is_non_localizable": false + }, + { + "maximum_text_length": null, + "name": "Meta keywords", + "guidelines": "Enter tags separated with a comma. Example: coffee, \"coffee roast\", grinder", + "is_required": false, "type": "text", - "id": "c44bf9dd-aa3f-41c7-b4d9-a09390e41e16", - "codename": "my_metadata__meta_title", - "external_id": "my-meta-title" + "external_id": "5efb2425-5987-a4a6-a2d3-b14712b56e73", + "id": "0ee20a72-0aaa-521f-8801-df3d9293b7dd", + "codename": "meta_keywords", + "is_non_localizable": true + }, + { + "guidelines": "Provide all personas for which this article is relevant.", + "taxonomy_group": { + "id": "7fad4cb0-c96c-5bce-a6c4-ec02a0280632" + }, + "is_required": false, + "term_count_limit": null, + "type": "taxonomy", + "external_id": "0a16b642-ac3e-584d-a45a-ba354a30b2bd", + "id": "c1dc36b5-558d-55a2-8f31-787430a68e4d", + "codename": "personas", + "is_non_localizable": true }, { + "maximum_text_length": null, "name": "Meta description", - "guidelines": "Length: 70-150 characters", + "guidelines": "Sum up the blog for SEO purposes. Limit for the meta description is 160 characters.", + "is_required": false, "type": "text", - "id": "59b9800b-81a9-4720-bef0-d4cecbaa646c", - "codename": "my_metadata__meta_description", - "external_id": "my-meta-description" + "external_id": "b9dc537c-2518-e4f5-8325-ce4fce26171e", + "id": "7df0048f-eaaf-50f8-85cf-fa0fc0d6d815", + "codename": "meta_description", + "is_non_localizable": true } ] } \ No newline at end of file diff --git a/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/PutLanguageVariantResponse.json b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/PutLanguageVariantResponse.json index be6eb5ec2..f7ca5fc5e 100644 --- a/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/PutLanguageVariantResponse.json +++ b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/PutLanguageVariantResponse.json @@ -2,49 +2,191 @@ "elements": [ { "element": { - "id": "108ed7c0-fc8c-c0ec-d0b5-5a8071408b54" + "id": "ba7c8840-bcbc-5e3b-b292-24d0a60f3977" + }, + "value": "On Roasts" + }, + { + "searchable_value": "Almighty form!", + "element": { + "id": "47bf7d6d-285d-4ed0-9919-1d1a98b43acd" + }, + "value": "{\"formId\": 42}" + }, + { + "element": { + "id": "773940f4-9e67-4a26-a93f-67e55fd7d837" + }, + "value": 3.14 + }, + { + "element": { + "id": "53a25074-b136-4a1f-a16d-3c130f696c66" }, "value": [ { - "id": "117cdfae-52cf-4885-b271-66aef6825612" + "id": "00c0f86a-7c51-4e60-abeb-a150e9092e53" + }, + { + "id": "8972dc90-ae2e-416e-995d-95df6c77e3b2" } ] }, { "element": { - "id": "f2ff5e3f-a9ca-4604-58b0-34a2ad6a7cf1" + "id": "9c6a4fbc-3f73-585f-9521-8d57636adf56" }, - "mode": "autogenerated", - "value": "my-articles-title" + "value": [ + { + "renditions": [], + "id": "5c08a538-5b58-44eb-81ef-43fb37eeb815" + }, + { + "renditions": [ + { + "id": "043d8f8b-22cb-4322-a1de-8a96c57548a3" + }, + { + "id": "7538b9b1-bb5f-493e-b9ab-24578e2a55f5" + } + ], + "id": "39c947ab-78ee-4de0-9bbd-8b79008111cc" + } + ] + }, + { + "element": { + "id": "0827e079-3754-5a1d-9381-8ff695a5bbf7" + }, + "value": "2017-07-04T00:00:00Z", + "display_timezone": "Europe/Prague" + }, + { + "element": { + "id": "15517aa3-da8a-5551-a4d4-555461fd5226" + }, + "value": "Summary" }, { + "components": [ + { + "id": "46c05bd9-d418-4507-836c-9accc5a39db3", + "type": { + "id": "17ff8a28-ebe6-5c9d-95ea-18fe1ff86d2d" + }, + "elements": [ + { + "element": { + "id": "f09fb430-2a58-59cf-be03-621e1c367501" + }, + "value": "https://twitter.com/ChrastinaOndrej/status/1417105245935706123" + }, + { + "element": { + "id": "05017deb-18b2-5094-b367-e7f8796dd1b8" + }, + "value": [ + { + "id": "061e69f7-0965-5e37-97bc-29963cfaebe8" + } + ] + }, + { + "element": { + "id": "19e38194-a3a8-5d17-abed-9b70d7a5fd25" + }, + "value": [ + { + "id": "dd78b09e-4337-599c-9701-20a0a165c63b" + } + ] + } + ] + } + ], "element": { - "id": "63793ba4-6004-a93c-68ca-52a1f0482bca" + "id": "55a88ab3-4009-5bf9-a590-f32162f09b92" + }, + "value": "

Light Roasts

Usually roasted for 6 - 8 minutes or simply until achieving a light brown color.This method is used for milder coffee varieties and for coffee tasting.This type of roasting allows the natural characteristics of each coffee to show.The aroma of coffees produced from light roasts is usually more intense.The cup itself is more acidic and the concentration of caffeine is higher.

" + }, + { + "element": { + "id": "77108990-3c30-5ffb-8dcd-8eb85fc52cb1" }, "value": [ { - "id": "f6daed1f-3f3b-4036-a9c7-9519359b9601" - }, + "id": "4b628214-e4fe-4fe0-b1ff-955df33e1515" + } + ] + }, + { + "element": { + "id": "0ee20a72-0aaa-521f-8801-df3d9293b7dd" + }, + "value": "MetaKeywords" + }, + { + "element": { + "id": "c1dc36b5-558d-55a2-8f31-787430a68e4d" + }, + "value": [ { - "id": "a6daed1f-4f3b-4037-a9c1-9537359b9600" + "id": "6e8b18d5-c5e3-5fc1-9014-44c18ef5f5d8" } ] }, { "element": { - "id": "68f65095-c9b4-05d6-a473-2883c2f0c7af" + "id": "7df0048f-eaaf-50f8-85cf-fa0fc0d6d815" + }, + "value": "MetaDescription" + }, + { + "mode": "custom", + "element": { + "id": "1f37e15b-27a0-5f48-b314-03b401c19cee" + }, + "value": "on-roasts" + }, + { + "element": { + "id": "a29858ff-fa9f-5841-a682-d7fb6cc6effe" }, - "value": "A tiny little plaintext." + "value": [ + { + "id": "4b628214-e4fe-4fe0-b1ff-955df33e1515" + } + ] } ], - "workflow_step": { - "id": "c4c2904a-e18c-48ae-9835-2405923262ba" + "schedule": { + "publish_time": "2024-03-31T08:00:00", + "publish_display_timezone": "Europe/Prague", + "unpublish_time": "2024-04-30T08:00:00", + "unpublish_display_timezone": "Europe/Prague" + }, + "workflow": { + "workflow_identifier": { + "id": "00000000-0000-0000-0000-000000000000" + }, + "step_identifier": { + "id": "eee6db3b-545a-4785-8e86-e3772c8756f9" + } }, + "due_date": { + "value": "2092-01-07T06:04:00.7069564Z" + }, + "contributors": [ + { + "id": "4b628214-e4fe-4fe0-b1ff-955df33e1515" + } + ], + "note": "Just a note", "item": { - "id": "f4b3fc05-e988-4dae-9ac1-a94aba566474" + "id": "4b628214-e4fe-4fe0-b1ff-955df33e1515" }, "language": { - "id": "d1f95fde-af02-b3b5-bd9e-f232311ccab8" + "id": "78dbefe8-831b-457e-9352-f4c4eacd5024" }, - "last_modified": "2020-11-21T09:08:33.2229841Z" + "last_modified": "2021-11-06T13:57:26.7069564Z" } \ No newline at end of file diff --git a/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/Readme/ArticleLanguageVariantResponse.json b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/Readme/ArticleLanguageVariantResponse.json deleted file mode 100644 index c0586c700..000000000 --- a/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/Readme/ArticleLanguageVariantResponse.json +++ /dev/null @@ -1,129 +0,0 @@ -{ - "elements": [ - { - "element": { - "id": "35a9faae-e502-4e26-a824-26b90b9b2ecd" - }, - "value": "On Roasts" - }, - { - "element": { - "id": "abe785d6-9146-4cab-8096-cba555d3840f" - }, - "value": "2017-07-04T00:00:00Z", - "display_timezone": null - }, - { - "components": [ - { - "id": "04bc8d32-97ab-431a-abaa-83102fc4c198", - "type": { - "id": "fdccd018-5c85-421e-bc01-884a69c514e4" - }, - "elements": [ - { - "element": { - "id": "35a9faae-e502-4e26-a824-26b90b9b2ecd" - }, - "value": "Article component title" - }, - { - "element": { - "id": "abe785d6-9146-4cab-8096-cba555d3840f" - }, - "value": null - }, - { - "components": [], - "element": { - "id": "bc872953-8507-4c98-9bb7-e9e2a546edb9" - }, - "value": "


" - }, - { - "element": { - "id": "3ba9d793-c544-4336-925d-69c3dc485445" - }, - "value": [] - }, - { - "element": { - "id": "9ec81a7d-c93a-4d62-adbb-c28fd8a9f3c8" - }, - "value": [] - }, - { - "mode": "autogenerated", - "element": { - "id": "b76e39e8-d3b4-4ed4-87d8-56fb90e0e342" - }, - "value": "" - } - ] - } - ], - "element": { - "id": "bc872953-8507-4c98-9bb7-e9e2a546edb9" - }, - "value": "

Rich Text

\n" - }, - { - "element": { - "id": "3ba9d793-c544-4336-925d-69c3dc485445" - }, - "value": [ - { - "id": "b4e7bfaa-593c-4ae4-a231-5136b10757b8" - }, - { - "id": "6d1c8ee9-76bc-474f-b09f-8a54a98f06ea" - } - ] - }, - { - "element": { - "id": "9ec81a7d-c93a-4d62-adbb-c28fd8a9f3c8" - }, - "value": [ - { - "id": "5c060bf3-ed38-4c77-acfa-9868e6e2b5dd" - } - ] - }, - { - "mode": "custom", - "element": { - "id": "b76e39e8-d3b4-4ed4-87d8-56fb90e0e342" - }, - "value": "on-roasts" - } - ], - "workflow_step": { - "id": "eee6db3b-545a-4785-8e86-e3772c8756f9" - }, - "item": { - "id": "9539c671-d578-4fd3-aa5c-b2d8e486c9b8" - }, - "language": { - "id": "00000000-0000-0000-0000-000000000000" - }, - "last_modified": "2021-11-10T11:30:19.1749134Z", - "workflow": { - "workflow_identifier": { - "id": "00000000-0000-0000-0000-000000000000" - }, - "step_identifier": { - "id": "00000000-0000-0000-0000-000000000000" - } - }, - "schedule": { - "publish_time": null, - "publish_display_timezone": null, - "unpublish_time": null, - "unpublish_display_timezone": null - }, - "due_date": { - "value": null - }, - "contributors": [] -} \ No newline at end of file diff --git a/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/Workflow.json b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/Workflow.json index 730192f0c..47dc53b61 100644 --- a/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/Workflow.json +++ b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/Workflow.json @@ -1,58 +1,69 @@ { - "id": "8bfdb62d-7aa1-473b-9d80-311ef93db108", - "name": "My workflow", - "codename": "my_workflow", + "id": "9f74c888-a49a-44e3-8526-d2a3fab50041", + "name": "Marketing", + "codename": "marketing", "scopes": [ { + "id": "f5937d83-e5ca-4021-bf9f-821109d52a37", "collections": [ { - "id": "1aeb9220-f167-4f8e-a7db-1bfec365fa80" + "id": "bbab58d7-5f33-4741-a71d-f5435586519c" } ], "content_types": [ { - "id": "1aeb9220-f167-4f8e-a7db-1bfec365fa80" + "id": "b33a98e8-2d0b-409a-a601-3df59edd82be" } ] } ], "steps": [ { - "name": "First step", - "codename": "first_step", - "color": "sky-blue", + "id": "64eea830-bf59-4534-bb97-3861a74ea7f1", + "name": "Draft", + "codename": "draft_2", + "color": "red", "transitions_to": [ { "step": { - "codename": "second_step" + "id": "c199950d-99f0-4983-b711-6c4c91624b22" } - } - ], - "role_ids": [] - }, - { - "name": "Second step", - "codename": "second_step", - "color": "rose", - "transitions_to": [ + }, { "step": { - "codename": "published" + "id": "7a535a69-ad34-47f8-806a-def1fdf4d391" } } ], "role_ids": [ - "e796887c-38a1-4ab2-a999-c40861bb7a4b" + "b28a237e-e821-4d7d-a5bd-e69e158887d6" ] } ], "published_step": { + "id": "c199950d-99f0-4983-b711-6c4c91624b22", + "name": "Published", + "codename": "published", + "transitions_to": [], "unpublish_role_ids": [ - "e796887c-38a1-4ab2-a999-c40861bb7a4b" + "b28a237e-e821-4d7d-a5bd-e69e158887d6" ], - "create_new_version_role_ids": [] + "create_new_version_role_ids": [ + "b28a237e-e821-4d7d-a5bd-e69e158887d6" + ] + }, + "scheduled_step": { + "id": "9d2b0228-4d0d-4c23-8b49-01a698857709", + "name": "Scheduled", + "codename": "scheduled" }, "archived_step": { - "role_ids": [] + "id": "7a535a69-ad34-47f8-806a-def1fdf4d391", + "name": "Archived", + "codename": "archived", + "transitions_to": [], + "role_ids": [ + "b28a237e-e821-4d7d-a5bd-e69e158887d6" + ] } } \ No newline at end of file diff --git a/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/WorkflowSteps.json b/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/WorkflowSteps.json deleted file mode 100644 index c3ab177ac..000000000 --- a/src/management/Kontent.Ai.Management.Tests/Data/CodeSamples/WorkflowSteps.json +++ /dev/null @@ -1,29 +0,0 @@ -[ - { - "id": "eee6db3b-545a-4785-8e86-e3772c8756f9", - "name": "Draft", - "codename": "draft", - "transitions_to": [ - "c199950d-99f0-4983-b711-6c4c91624b22", - "7a535a69-ad34-47f8-806a-def1fdf4d391" - ] - }, - { - "id": "9d2b0228-4d0d-4c23-8b49-01a698857709", - "name": "Scheduled", - "codename": "scheduled", - "transitions_to": [] - }, - { - "id": "c199950d-99f0-4983-b711-6c4c91624b22", - "name": "Published", - "codename": "published", - "transitions_to": [] - }, - { - "id": "7a535a69-ad34-47f8-806a-def1fdf4d391", - "name": "Archived", - "codename": "archived", - "transitions_to": [] - } -] \ No newline at end of file diff --git a/src/management/Kontent.Ai.Management.Tests/Data/Language/ListLanguages_ListsLanguages.json b/src/management/Kontent.Ai.Management.Tests/Data/Language/ListLanguages_ListsLanguages.json deleted file mode 100644 index 0f4edc479..000000000 --- a/src/management/Kontent.Ai.Management.Tests/Data/Language/ListLanguages_ListsLanguages.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "languages": [ - { - "id": "00000000-0000-0000-0000-000000000000", - "name": "Default project language", - "codename": "default", - "external_id": "string", - "is_active": true, - "is_default": true, - "fallback_language": { - "id": "00000000-0000-0000-0000-000000000000" - } - }, - { - "id": "0080e2ba-5c66-4067-a80a-5a81658cbe64", - "name": "German", - "codename": "de-DE", - "external_id": "german", - "is_active": true, - "is_default": false, - "fallback_language": { - "id": "00000000-0000-0000-0000-000000000000" - } - } - ], - "pagination": { - "continuation_token": "+RID:~...", - "next_page": "https://manage.kontent.ai/v2/projects//?continuationToken=%2bRID%3a~..." - } -} \ No newline at end of file diff --git a/src/management/Kontent.Ai.Management.Tests/Data/LanguageVariant/LanguageVariant.json b/src/management/Kontent.Ai.Management.Tests/Data/LanguageVariant/LanguageVariant.json index b0f3575ad..f7ca5fc5e 100644 --- a/src/management/Kontent.Ai.Management.Tests/Data/LanguageVariant/LanguageVariant.json +++ b/src/management/Kontent.Ai.Management.Tests/Data/LanguageVariant/LanguageVariant.json @@ -59,7 +59,7 @@ "id": "0827e079-3754-5a1d-9381-8ff695a5bbf7" }, "value": "2017-07-04T00:00:00Z", - "display_timezone": "Prague" + "display_timezone": "Europe/Prague" }, { "element": { diff --git a/src/management/Kontent.Ai.Management.Tests/Data/LanguageVariant/LanguageVariants.json b/src/management/Kontent.Ai.Management.Tests/Data/LanguageVariant/LanguageVariants.json index cc01016ae..201f75604 100644 --- a/src/management/Kontent.Ai.Management.Tests/Data/LanguageVariant/LanguageVariants.json +++ b/src/management/Kontent.Ai.Management.Tests/Data/LanguageVariant/LanguageVariants.json @@ -60,7 +60,7 @@ "id": "0827e079-3754-5a1d-9381-8ff695a5bbf7" }, "value": "2017-07-04T00:00:00Z", - "display_timezone": "Prague" + "display_timezone": "Europe/Prague" }, { "element": { @@ -252,7 +252,7 @@ "id": "0827e079-3754-5a1d-9381-8ff695a5bbf7" }, "value": "2017-07-04T00:00:00Z", - "display_timezone": "Prague" + "display_timezone": "Europe/Prague" }, { "element": { diff --git a/src/management/Kontent.Ai.Management.Tests/Data/LanguageVariant/LanguageVariantsPage1.json b/src/management/Kontent.Ai.Management.Tests/Data/LanguageVariant/LanguageVariantsPage1.json index 9b19420dd..1150f92ad 100644 --- a/src/management/Kontent.Ai.Management.Tests/Data/LanguageVariant/LanguageVariantsPage1.json +++ b/src/management/Kontent.Ai.Management.Tests/Data/LanguageVariant/LanguageVariantsPage1.json @@ -61,7 +61,7 @@ "id": "0827e079-3754-5a1d-9381-8ff695a5bbf7" }, "value": "2017-07-04T00:00:00Z", - "display_timezone": "Prague" + "display_timezone": "Europe/Prague" }, { "element": { @@ -253,7 +253,7 @@ "id": "0827e079-3754-5a1d-9381-8ff695a5bbf7" }, "value": "2017-07-04T00:00:00Z", - "display_timezone": "Prague" + "display_timezone": "Europe/Prague" }, { "element": { diff --git a/src/management/Kontent.Ai.Management.Tests/Data/LanguageVariant/LanguageVariantsPage2.json b/src/management/Kontent.Ai.Management.Tests/Data/LanguageVariant/LanguageVariantsPage2.json index 397d2ed8d..889130405 100644 --- a/src/management/Kontent.Ai.Management.Tests/Data/LanguageVariant/LanguageVariantsPage2.json +++ b/src/management/Kontent.Ai.Management.Tests/Data/LanguageVariant/LanguageVariantsPage2.json @@ -61,7 +61,7 @@ "id": "0827e079-3754-5a1d-9381-8ff695a5bbf7" }, "value": "2017-07-04T00:00:00Z", - "display_timezone": "Prague" + "display_timezone": "Europe/Prague" }, { "element": { @@ -253,7 +253,7 @@ "id": "0827e079-3754-5a1d-9381-8ff695a5bbf7" }, "value": "2017-07-04T00:00:00Z", - "display_timezone": "Prague" + "display_timezone": "Europe/Prague" }, { "element": { diff --git a/src/management/Kontent.Ai.Management.Tests/Data/LanguageVariant/LanguageVariantsPage3.json b/src/management/Kontent.Ai.Management.Tests/Data/LanguageVariant/LanguageVariantsPage3.json index ca5f6808e..526e146ba 100644 --- a/src/management/Kontent.Ai.Management.Tests/Data/LanguageVariant/LanguageVariantsPage3.json +++ b/src/management/Kontent.Ai.Management.Tests/Data/LanguageVariant/LanguageVariantsPage3.json @@ -61,7 +61,7 @@ "id": "0827e079-3754-5a1d-9381-8ff695a5bbf7" }, "value": "2017-07-04T00:00:00Z", - "display_timezone": "Prague" + "display_timezone": "Europe/Prague" }, { "element": { @@ -253,7 +253,7 @@ "id": "0827e079-3754-5a1d-9381-8ff695a5bbf7" }, "value": "2017-07-04T00:00:00Z", - "display_timezone": "Prague" + "display_timezone": "Europe/Prague" }, { "element": { diff --git a/src/management/Kontent.Ai.Management.Tests/Data/ProjectValidation/ExpectedAsyncValidationTaskIssues.json b/src/management/Kontent.Ai.Management.Tests/Data/ProjectValidation/ExpectedAsyncValidationTaskIssues.json deleted file mode 100644 index 22ccb2091..000000000 --- a/src/management/Kontent.Ai.Management.Tests/Data/ProjectValidation/ExpectedAsyncValidationTaskIssues.json +++ /dev/null @@ -1,27 +0,0 @@ -[ - { - "item": { - "id": "2a67418c-1df4-4196-8efd-107521963fba", - "name": "The item", - "codename": "the_item" - }, - "language": { - "id": "00000000-0000-0000-0000-000000000000", - "name": "Default project language", - "codename": "default" - }, - "issues": [ - { - "element": { - "id": "42facc8c-ed7a-4843-8e74-61aa6fbdbc09", - "name": "Taxo", - "codename": "taxo" - }, - "messages": [ - "Element 'Taxo' references a non-existent taxonomy term 7c24abc5-93c0-4481-b3cb-39b80c25d4d6." - ] - } - ], - "issue_type": "variant_issue" - } -] \ No newline at end of file diff --git a/src/management/Kontent.Ai.Management.Tests/Handlers/ResiliencePipelineTests.cs b/src/management/Kontent.Ai.Management.Tests/Handlers/ResiliencePipelineTests.cs index 620261d24..90f019c7b 100644 --- a/src/management/Kontent.Ai.Management.Tests/Handlers/ResiliencePipelineTests.cs +++ b/src/management/Kontent.Ai.Management.Tests/Handlers/ResiliencePipelineTests.cs @@ -3,6 +3,7 @@ using Kontent.Ai.Management.Extensions; using Microsoft.Extensions.Http.Resilience; using Polly; +using Polly.Timeout; using System.Net; using System.Net.Http.Headers; using System.Net.Sockets; @@ -99,6 +100,15 @@ public void IsTransientException_InvalidOperationException_ReturnsFalse() HttpRetryPredicates.IsTransientException(new InvalidOperationException(), CancellationToken.None).Should().BeFalse(); } + // The management pipeline adds no per-attempt timeout, so this case cannot arise here. The predicate is + // shared source though, and it is pinned identically in all three suites so a change to it cannot pass + // one product's tests while breaking another's. + [Fact] + public void IsTransientException_TimeoutRejectedException_ReturnsTrue() + { + HttpRetryPredicates.IsTransientException(new TimeoutRejectedException(), CancellationToken.None).Should().BeTrue(); + } + // Retry-After handling is deliberately left to HttpRetryStrategyOptions' built-in ShouldRetryAfterHeader default // (it honors both delta and HTTP-date forms); these pin that the default pipeline actually applies it. [Fact] diff --git a/src/management/Kontent.Ai.Management.Tests/ManagementClientTests/AssetFolderTests.cs b/src/management/Kontent.Ai.Management.Tests/ManagementClientTests/AssetFolderTests.cs index 5e1f8d094..f435b043e 100644 --- a/src/management/Kontent.Ai.Management.Tests/ManagementClientTests/AssetFolderTests.cs +++ b/src/management/Kontent.Ai.Management.Tests/ManagementClientTests/AssetFolderTests.cs @@ -112,8 +112,12 @@ public async Task ModifyAssetFoldersAsync_ChangesAreNull_Throws() }, new AssetFolderRenamePatchModel { + Reference = Reference.ByCodename("codename"), Value = "new folder name", }, - new AssetFolderRemovePatchModel() + new AssetFolderRemovePatchModel + { + Reference = Reference.ByCodename("codename") + } }; } diff --git a/src/management/Kontent.Ai.Management.Tests/ManagementClientTests/EnvironmentUserTests.cs b/src/management/Kontent.Ai.Management.Tests/ManagementClientTests/EnvironmentUserTests.cs index bd8acd609..23ed57132 100644 --- a/src/management/Kontent.Ai.Management.Tests/ManagementClientTests/EnvironmentUserTests.cs +++ b/src/management/Kontent.Ai.Management.Tests/ManagementClientTests/EnvironmentUserTests.cs @@ -14,7 +14,7 @@ private static string Fixture(string name) => File.ReadAllText(Path.Combine(Environment.CurrentDirectory, "Data", "ProjectUser", name)); [Fact] - public async Task InviteUserIntoProjectAsync_InvitesUser() + public async Task InviteUserIntoEnvironmentAsync_InvitesUser() { var (client, mock) = MockClientFactory.Create(); var invitation = new UserInviteModel @@ -48,7 +48,7 @@ public async Task InviteUserIntoProjectAsync_InvitesUser() } [Fact] - public async Task InviteUserIntoProjectAsync_UserInvitationModelIsNull_Throws() + public async Task InviteUserIntoEnvironmentAsync_UserInvitationModelIsNull_Throws() { var (client, _) = MockClientFactory.Create(); diff --git a/src/management/Kontent.Ai.Management.Tests/ManagementClientTests/FailureShapeTests.cs b/src/management/Kontent.Ai.Management.Tests/ManagementClientTests/FailureShapeTests.cs new file mode 100644 index 000000000..aab89cc93 --- /dev/null +++ b/src/management/Kontent.Ai.Management.Tests/ManagementClientTests/FailureShapeTests.cs @@ -0,0 +1,104 @@ +using System.Net; +using AwesomeAssertions; +using Kontent.Ai.Management.Conversion; +using Kontent.Ai.Management.Models.LanguageVariants; +using Kontent.Ai.Management.Tests.Base; +using MyProject.Models; +using RichardSzalay.MockHttp; + +namespace Kontent.Ai.Management.Tests.ManagementClientTests; + +/// +/// What reaches the caller when the call does not succeed. The result pattern is only worth documenting +/// as an absolute if it holds for the failures that are not API errors too - a consumer who is told some +/// failures throw will write a catch that never runs, and skip the IsSuccess check that +/// would have caught them. +/// +public class FailureShapeTests +{ + [Fact] + public async Task ApiError_IsAFailedResult() + { + var (client, mock) = MockClientFactory.Create(); + mock.When($"{MockClientFactory.BaseUrl}/items/codename/x") + .Respond(HttpStatusCode.NotFound, "application/json", """{"message":"not found","request_id":"r","error_code":100}"""); + + var result = await client.GetContentItemAsync(Reference.ByCodename("x")); + + result.IsSuccess.Should().BeFalse(); + result.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task TransportFailure_IsAFailedResultCarryingTheException() + { + var (client, mock) = MockClientFactory.Create(); + mock.When($"{MockClientFactory.BaseUrl}/items/codename/x") + .Respond(_ => throw new HttpRequestException("No such host is known.")); + + var result = await client.GetContentItemAsync(Reference.ByCodename("x")); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Exception.Should().NotBeNull(); + } + + [Fact] + public async Task UnreadableSuccessBody_IsAFailedResult() + { + // A 200 whose body does not deserialize. Refit captures that into the response rather than + // throwing, so it arrives as a failed result like any other. + var (client, mock) = MockClientFactory.Create(); + mock.When($"{MockClientFactory.BaseUrl}/items/codename/x") + .Respond(HttpStatusCode.OK, "application/json", "{ this is not json"); + + var result = await client.GetContentItemAsync(Reference.ByCodename("x")); + + result.IsSuccess.Should().BeFalse(); + result.Error.Should().NotBeNull(); + } + + [Fact] + public async Task CallerCancellation_IsTheOneThingThatThrows() + { + var (client, mock) = MockClientFactory.Create(); + using var cts = new CancellationTokenSource(); + mock.When($"{MockClientFactory.BaseUrl}/items/codename/x") + .Respond(_ => + { + cts.Cancel(); + cts.Token.ThrowIfCancellationRequested(); + return new HttpResponseMessage(HttpStatusCode.OK); + }); + + var act = async () => await client.GetContentItemAsync(Reference.ByCodename("x"), cts.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task TypedVariantConversionFailure_Throws() + { + // The typed overloads project the response onto the generated record after the result is built, and + // that projection is not guarded - so a model that has drifted from the content type surfaces as a + // throw rather than a failed result. The only exception besides cancellation, and worth documenting + // on those overloads specifically. + var registry = new ContentTypeRegistry(); + registry.Register(typeof(Callout)); + var (client, mock) = MockClientFactory.Create(new ContentItemEnvelopeConverter(registry)); + + var fixture = await File.ReadAllTextAsync(Path.Combine( + Environment.CurrentDirectory, "Data", "LanguageVariant", "StronglyTypedCalloutVariant.json")); + var body = fixture.Replace("\"warning\"", "\"unknown-option\""); + + mock.When($"{MockClientFactory.BaseUrl}/items/codename/my_article/variants/codename/en-US") + .Respond(HttpStatusCode.OK, "application/json", body); + + var identifier = new LanguageVariantIdentifier( + Reference.ByCodename("my_article"), + Reference.ByCodename("en-US")); + + var act = async () => await client.GetLanguageVariantAsync(identifier); + + await act.Should().ThrowAsync().WithMessage("*unknown-option*"); + } +} diff --git a/src/management/Kontent.Ai.Management.Tests/ManagementClientTests/PublishingTests.cs b/src/management/Kontent.Ai.Management.Tests/ManagementClientTests/PublishingTests.cs index 0030fdfd0..b416d8fc4 100644 --- a/src/management/Kontent.Ai.Management.Tests/ManagementClientTests/PublishingTests.cs +++ b/src/management/Kontent.Ai.Management.Tests/ManagementClientTests/PublishingTests.cs @@ -85,7 +85,7 @@ public async Task SchedulePublishingOfLanguageVariantAsync_SchedulesPublishingVa var (client, mock) = MockClientFactory.Create(); var schedule = new ScheduleModel { - DisplayTimeZone = "prague", + DisplayTimeZone = "Europe/Prague", ScheduledTo = DateTimeOffset.UtcNow }; @@ -106,7 +106,7 @@ public async Task SchedulePublishingOfLanguageVariantAsync_NoIdentifier_Throws() var (client, _) = MockClientFactory.Create(); var schedule = new ScheduleModel { - DisplayTimeZone = "prague", + DisplayTimeZone = "Europe/Prague", ScheduledTo = DateTimeOffset.UtcNow }; @@ -120,9 +120,9 @@ public async Task SchedulePublishingAndUnpublishingOfLanguageVariantAsync_Schedu var (client, mock) = MockClientFactory.Create(); var schedule = new SchedulePublishAndUnpublishModel() { - PublishDisplayTimeZone = "prague", + PublishDisplayTimeZone = "Europe/Prague", PublishScheduledTo = DateTimeOffset.UtcNow, - UnpublishDisplayTimeZone = "prague", + UnpublishDisplayTimeZone = "Europe/Prague", UnpublishScheduledTo = DateTimeOffset.UtcNow.AddDays(10) }; @@ -143,9 +143,9 @@ public async Task SchedulePublishingAndUnpublishingOfLanguageVariantAsync_NoIden var (client, _) = MockClientFactory.Create(); var schedule = new SchedulePublishAndUnpublishModel() { - PublishDisplayTimeZone = "prague", + PublishDisplayTimeZone = "Europe/Prague", PublishScheduledTo = DateTimeOffset.UtcNow, - UnpublishDisplayTimeZone = "prague", + UnpublishDisplayTimeZone = "Europe/Prague", UnpublishScheduledTo = DateTimeOffset.UtcNow.AddDays(10) }; @@ -238,7 +238,7 @@ public async Task ScheduleUnpublishingOfLanguageVariantAsync_SchedulesUnpublishi var (client, mock) = MockClientFactory.Create(); var schedule = new ScheduleModel { - DisplayTimeZone = "prague", + DisplayTimeZone = "Europe/Prague", ScheduledTo = DateTimeOffset.UtcNow }; @@ -259,7 +259,7 @@ public async Task ScheduleUnpublishingOfLanguageVariantAsync_NoIdentifier_Throws var (client, _) = MockClientFactory.Create(); var schedule = new ScheduleModel { - DisplayTimeZone = "prague", + DisplayTimeZone = "Europe/Prague", ScheduledTo = DateTimeOffset.UtcNow }; diff --git a/src/management/Kontent.Ai.Management.Tests/Serialization/DisplayTimeZoneContractTests.cs b/src/management/Kontent.Ai.Management.Tests/Serialization/DisplayTimeZoneContractTests.cs new file mode 100644 index 000000000..8f89b4ba1 --- /dev/null +++ b/src/management/Kontent.Ai.Management.Tests/Serialization/DisplayTimeZoneContractTests.cs @@ -0,0 +1,77 @@ +using System.Text.Json; +using AwesomeAssertions; + +namespace Kontent.Ai.Management.Tests.Serialization; + +/// +/// Every display_timezone the API sends is an IANA zone name, so a fixture carrying anything else +/// records a contract the server does not have and nothing built on reading it can be trusted. Resolution +/// is checked rather than the shape, because passing the value to is what a +/// consumer does with it, and a name that does not resolve is one no consumer can use. +/// +public class DisplayTimeZoneContractTests +{ + private static readonly string[] ZoneProperties = + ["display_timezone", "publish_display_timezone", "unpublish_display_timezone"]; + + [Fact] + public void EveryFixtureDisplayTimeZone_IsAResolvableIanaName() + { + var dataRoot = Path.Combine(Environment.CurrentDirectory, "Data"); + var files = Directory.GetFiles(dataRoot, "*.json", SearchOption.AllDirectories); + files.Should().NotBeEmpty(); + + var offenders = files + .SelectMany(file => ZoneValuesIn(file).Select(zone => (File: Path.GetFileName(file), Zone: zone))) + .Where(found => !TimeZoneInfo.TryFindSystemTimeZoneById(found.Zone, out _)) + .Distinct() + .ToList(); + + offenders.Should().BeEmpty( + "the API sends IANA zone names; found {0}", + string.Join(", ", offenders.Select(o => $"\"{o.Zone}\" in {o.File}"))); + } + + private static IEnumerable ZoneValuesIn(string file) + { + // A fixture may be an empty body, which is a response the API really does send. + var json = File.ReadAllText(file); + if (string.IsNullOrWhiteSpace(json)) + { + return []; + } + + using var document = JsonDocument.Parse(json); + return [.. Collect(document.RootElement)]; + } + + private static IEnumerable Collect(JsonElement element) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (var property in element.EnumerateObject()) + { + if (ZoneProperties.Contains(property.Name) && property.Value.ValueKind == JsonValueKind.String) + { + yield return property.Value.GetString()!; + } + + foreach (var nested in Collect(property.Value)) + { + yield return nested; + } + } + + break; + + case JsonValueKind.Array: + foreach (var nested in element.EnumerateArray().SelectMany(Collect)) + { + yield return nested; + } + + break; + } + } +} diff --git a/src/management/Kontent.Ai.Management.Tests/Serialization/PatchOperationConverterTests.cs b/src/management/Kontent.Ai.Management.Tests/Serialization/PatchOperationConverterTests.cs index 95091c5ce..927555605 100644 --- a/src/management/Kontent.Ai.Management.Tests/Serialization/PatchOperationConverterTests.cs +++ b/src/management/Kontent.Ai.Management.Tests/Serialization/PatchOperationConverterTests.cs @@ -21,8 +21,8 @@ public void Write_HeterogeneousOps_EmitsRuntimeTypeInOrder() var operations = new List { new AssetFolderAddIntoPatchModel { Value = new AssetFolderHierarchy { Name = "new-folder" } }, - new AssetFolderRemovePatchModel(), - new AssetFolderRenamePatchModel { Value = "renamed" }, + new AssetFolderRemovePatchModel { Reference = Reference.ByCodename("folder") }, + new AssetFolderRenamePatchModel { Reference = Reference.ByCodename("folder"), Value = "renamed" }, }; var array = JsonNode.Parse(JsonSerializer.Serialize(operations, Options))!.AsArray(); diff --git a/src/management/Kontent.Ai.Management/Error.cs b/src/management/Kontent.Ai.Management/Error.cs index 04606eac7..38933681e 100644 --- a/src/management/Kontent.Ai.Management/Error.cs +++ b/src/management/Kontent.Ai.Management/Error.cs @@ -1,4 +1,3 @@ - namespace Kontent.Ai.Management; /// diff --git a/src/management/Kontent.Ai.Management/Extensions/ServiceCollectionExtensions.HttpClient.cs b/src/management/Kontent.Ai.Management/Extensions/ServiceCollectionExtensions.HttpClient.cs index 2f61976b9..e954b922e 100644 --- a/src/management/Kontent.Ai.Management/Extensions/ServiceCollectionExtensions.HttpClient.cs +++ b/src/management/Kontent.Ai.Management/Extensions/ServiceCollectionExtensions.HttpClient.cs @@ -44,12 +44,6 @@ private static void RegisterRefitClient( }); } - private static RefitSettings CreateRefitSettings() - { - var settings = RefitSettingsProvider.CreateDefaultSettings(); - return settings; - } - /// /// Gives the client's connections a bounded lifetime so DNS changes are picked up. /// diff --git a/src/management/Kontent.Ai.Management/Extensions/ServiceCollectionExtensions.cs b/src/management/Kontent.Ai.Management/Extensions/ServiceCollectionExtensions.cs index 51250499b..be21513c1 100644 --- a/src/management/Kontent.Ai.Management/Extensions/ServiceCollectionExtensions.cs +++ b/src/management/Kontent.Ai.Management/Extensions/ServiceCollectionExtensions.cs @@ -295,7 +295,7 @@ private static IServiceCollection CompleteClientRegistration( Action? configureHttpClient, Action>? configureResilience) { - var refitSettings = CreateRefitSettings(); + var refitSettings = RefitSettingsProvider.CreateDefaultSettings(); RegisterRefitClient( services, diff --git a/src/management/Kontent.Ai.Management/IManagementClient.cs b/src/management/Kontent.Ai.Management/IManagementClient.cs index 99c457d5d..0e76b37e2 100644 --- a/src/management/Kontent.Ai.Management/IManagementClient.cs +++ b/src/management/Kontent.Ai.Management/IManagementClient.cs @@ -37,9 +37,20 @@ namespace Kontent.Ai.Management; /// instead of throwing on API errors. ///
/// +/// +/// The operations here span two scopes. Most resolve against an environment and need +/// ; the subscription-level ones +/// (*SubscriptionUser*, ListSubscriptionProjects*) resolve against +/// and never touch an environment. Only the +/// identifiers you configure are validated, so a client set up for one scope is legitimate — calling into +/// the scope you did not configure throws naming the missing +/// option, before the request is built. +/// +/// /// This contract carries no disposal. A client resolved from a container is owned by the container, /// which releases it; a client built standalone owns its s and is returned as /// the concrete , which is disposable. +/// /// public interface IManagementClient { @@ -313,7 +324,7 @@ public interface IManagementClient Task> GetCustomAppAsync(Reference identifier, CancellationToken cancellationToken = default); /// - /// Creates the custom apps. + /// Creates the custom app. /// /// Represents the custom app that will be created. /// Token to cancel the request. @@ -321,7 +332,7 @@ public interface IManagementClient Task> CreateCustomAppAsync(CustomAppCreateModel customApp, CancellationToken cancellationToken = default); /// - /// Modifies the custom apps. + /// Modifies the custom app. /// /// The identifier of the custom app. /// Represents changes that will be applied to the custom app. @@ -330,7 +341,7 @@ public interface IManagementClient Task> ModifyCustomAppAsync(Reference identifier, IEnumerable changes, CancellationToken cancellationToken = default); /// - /// Deletes the custom apps. + /// Deletes the custom app. /// /// The identifier of the custom app. /// Token to cancel the request. @@ -584,8 +595,10 @@ public interface IManagementClient /// /// Retrieves a language variant and projects its elements onto the generated content-type record - /// . Failures (HTTP 4xx/5xx) are surfaced through the returned result rather than thrown; - /// network-level and serialization failures still propagate as exceptions. + /// . Call failures are surfaced through the returned result rather than thrown — see + /// . The projection itself is the exception: a that no + /// longer matches the content type throws rather than returning a failed result, because it is a mismatch + /// between your model and the environment rather than an outcome of the call. /// /// /// Typed read is environment-bound: the projection matches the response's element and rich-text-component ids @@ -635,8 +648,9 @@ Task>> GetPublishedLanguageVariantAsyn /// /// Inserts or updates a language variant from the generated content-type record . - /// null properties are omitted from the payload (partial update). HTTP 4xx/5xx failures are surfaced - /// through the returned result rather than thrown; network-level and serialization failures still propagate as exceptions. + /// null properties are omitted from the payload (partial update). Call failures are surfaced through the + /// returned result rather than thrown — see . Projecting the response back onto + /// is the exception: a model that no longer matches the content type throws. /// /// /// The write keys off codenames and stays portable across environments, but the typed response projection is @@ -675,9 +689,9 @@ Task>> UpsertLanguageVariantAsync( Task> GetPreviewConfigurationAsync(CancellationToken cancellationToken = default); /// - /// Modify the preview configuration. + /// Replaces the preview configuration. /// - /// Represents configuration that will be used for project. + /// The preview configuration to store for the environment. /// Token to cancel the request. /// A result wrapping the on success, or the failure detail. Task> UpdatePreviewConfigurationAsync(PreviewConfigurationModel previewConfiguration, CancellationToken cancellationToken = default); @@ -816,7 +830,7 @@ Task>> UpsertLanguageVariantAsync( Task>> ListSubscriptionUsersAsync(CancellationToken cancellationToken = default); /// - /// Retrieve a user metadata from under the specified subscription. + /// Retrieves a user's metadata from under the specified subscription. /// The metadata include information about the user's access to projects and environments, /// and content in specific collections, roles, and languages. /// diff --git a/src/management/Kontent.Ai.Management/IManagementResult.cs b/src/management/Kontent.Ai.Management/IManagementResult.cs index 8bc76cddd..679c60994 100644 --- a/src/management/Kontent.Ai.Management/IManagementResult.cs +++ b/src/management/Kontent.Ai.Management/IManagementResult.cs @@ -3,9 +3,16 @@ namespace Kontent.Ai.Management; /// -/// The outcome of a Management SDK operation. Management API responses — including validation failures and other -/// 4xx/5xx errors — are surfaced here without throwing; inspect rather than catching. -/// Transport-level failures, where no HTTP response is received, still surface as exceptions. +/// The outcome of a Management SDK operation. Every failure of the call itself is surfaced here without +/// throwing — API validation failures and other 4xx/5xx errors, a transport failure that never reached the +/// server, and a response whose body could not be read. Inspect rather than catching; +/// where an exception caused the failure, carries it. +/// +/// Cancellation is the exception: a cancelled call throws , so +/// and cancellation handlers behave normally. Argument +/// and configuration validation throw as well, and EnsureSuccess() converts a failed result into a +/// throw on request. +/// /// public interface IManagementResult { diff --git a/src/management/Kontent.Ai.Management/Kontent.Ai.Management.csproj b/src/management/Kontent.Ai.Management/Kontent.Ai.Management.csproj index c68fd9d81..44c5a129c 100644 --- a/src/management/Kontent.Ai.Management/Kontent.Ai.Management.csproj +++ b/src/management/Kontent.Ai.Management/Kontent.Ai.Management.csproj @@ -10,7 +10,6 @@ - diff --git a/src/management/Kontent.Ai.Management/ManagementClientFactory.cs b/src/management/Kontent.Ai.Management/ManagementClientFactory.cs index 9aa71d99e..1ac212da2 100644 --- a/src/management/Kontent.Ai.Management/ManagementClientFactory.cs +++ b/src/management/Kontent.Ai.Management/ManagementClientFactory.cs @@ -11,15 +11,12 @@ public IManagementClient Get(string name) { ArgumentException.ThrowIfNullOrWhiteSpace(name); - try - { - return serviceProvider.GetRequiredKeyedService(name); - } - catch (InvalidOperationException ex) - { - throw new InvalidOperationException( - $"No management client registered with name '{name}'. Ensure you've registered the client using AddManagementClient(\"{name}\", ...).", - ex); - } + // Resolved with the nullable overload rather than catching: the client's own registration runs + // inside resolution, and anything it throws is also an InvalidOperationException - a + // configureHttpClient that rejected its input used to come back relabelled as a missing + // registration, pointing at the wrong thing entirely. + return serviceProvider.GetKeyedService(name) + ?? throw new InvalidOperationException( + $"No management client registered with name '{name}'. Ensure you've registered the client using AddManagementClient(\"{name}\", ...)."); } } diff --git a/src/management/Kontent.Ai.Management/Models/AssetFolders/Patch/AssetFolderAddIntoPatchModel.cs b/src/management/Kontent.Ai.Management/Models/AssetFolders/Patch/AssetFolderAddIntoPatchModel.cs index 70bb88d96..ed6563990 100644 --- a/src/management/Kontent.Ai.Management/Models/AssetFolders/Patch/AssetFolderAddIntoPatchModel.cs +++ b/src/management/Kontent.Ai.Management/Models/AssetFolders/Patch/AssetFolderAddIntoPatchModel.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.AssetFolders.Patch; +namespace Kontent.Ai.Management.Models.AssetFolders.Patch; /// /// Represents addInto operation to perform on the folder. @@ -11,6 +10,12 @@ public sealed record AssetFolderAddIntoPatchModel : AssetFolderOperationBaseMode /// public override string Op => "addInto"; + /// + /// Reference to the parent folder to add into; omit to add at the root. + /// + [JsonPropertyName("reference")] + public Reference? Reference { get; init; } + /// /// The folder to add. Required. /// diff --git a/src/management/Kontent.Ai.Management/Models/AssetFolders/Patch/AssetFolderOperationBaseModel.cs b/src/management/Kontent.Ai.Management/Models/AssetFolders/Patch/AssetFolderOperationBaseModel.cs index 602fe97a0..e24cbc67a 100644 --- a/src/management/Kontent.Ai.Management/Models/AssetFolders/Patch/AssetFolderOperationBaseModel.cs +++ b/src/management/Kontent.Ai.Management/Models/AssetFolders/Patch/AssetFolderOperationBaseModel.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.AssetFolders.Patch; +namespace Kontent.Ai.Management.Models.AssetFolders.Patch; /// /// Represents the operation on folders. @@ -11,11 +10,4 @@ public abstract record AssetFolderOperationBaseModel /// [JsonPropertyName("op")] public abstract string Op { get; } - - /// - /// Reference to an existing folder. Required for remove and rename; optional for addInto, where it identifies the parent folder to add into. - /// - [JsonPropertyName("reference")] - public Reference? Reference { get; init; } - } diff --git a/src/management/Kontent.Ai.Management/Models/AssetFolders/Patch/AssetFolderRemovePatchModel.cs b/src/management/Kontent.Ai.Management/Models/AssetFolders/Patch/AssetFolderRemovePatchModel.cs index b7e4b4244..d401cd5ea 100644 --- a/src/management/Kontent.Ai.Management/Models/AssetFolders/Patch/AssetFolderRemovePatchModel.cs +++ b/src/management/Kontent.Ai.Management/Models/AssetFolders/Patch/AssetFolderRemovePatchModel.cs @@ -9,4 +9,10 @@ public sealed record AssetFolderRemovePatchModel : AssetFolderOperationBaseModel /// Represents remove operation. ///
public override string Op => "remove"; + + /// + /// Reference to the folder to remove. + /// + [JsonPropertyName("reference")] + public required Reference Reference { get; init; } } diff --git a/src/management/Kontent.Ai.Management/Models/AssetFolders/Patch/AssetFolderRenamePatchModel.cs b/src/management/Kontent.Ai.Management/Models/AssetFolders/Patch/AssetFolderRenamePatchModel.cs index 8573c9b23..8d4b04518 100644 --- a/src/management/Kontent.Ai.Management/Models/AssetFolders/Patch/AssetFolderRenamePatchModel.cs +++ b/src/management/Kontent.Ai.Management/Models/AssetFolders/Patch/AssetFolderRenamePatchModel.cs @@ -1,4 +1,3 @@ - namespace Kontent.Ai.Management.Models.AssetFolders.Patch; /// @@ -11,6 +10,12 @@ public sealed record AssetFolderRenamePatchModel : AssetFolderOperationBaseModel /// public override string Op => "rename"; + /// + /// Reference to the folder to rename. + /// + [JsonPropertyName("reference")] + public required Reference Reference { get; init; } + /// /// New folder name. /// diff --git a/src/management/Kontent.Ai.Management/Models/AssetRenditions/AssetRenditionCreateModel.cs b/src/management/Kontent.Ai.Management/Models/AssetRenditions/AssetRenditionCreateModel.cs index a41445d76..76b202c0c 100644 --- a/src/management/Kontent.Ai.Management/Models/AssetRenditions/AssetRenditionCreateModel.cs +++ b/src/management/Kontent.Ai.Management/Models/AssetRenditions/AssetRenditionCreateModel.cs @@ -1,4 +1,3 @@ - namespace Kontent.Ai.Management.Models.AssetRenditions; /// diff --git a/src/management/Kontent.Ai.Management/Models/AssetRenditions/AssetRenditionUpdateModel.cs b/src/management/Kontent.Ai.Management/Models/AssetRenditions/AssetRenditionUpdateModel.cs index 9ae339121..6b1fb7058 100644 --- a/src/management/Kontent.Ai.Management/Models/AssetRenditions/AssetRenditionUpdateModel.cs +++ b/src/management/Kontent.Ai.Management/Models/AssetRenditions/AssetRenditionUpdateModel.cs @@ -1,4 +1,3 @@ - namespace Kontent.Ai.Management.Models.AssetRenditions; /// diff --git a/src/management/Kontent.Ai.Management/Models/AssetRenditions/ImageTransformationFit.cs b/src/management/Kontent.Ai.Management/Models/AssetRenditions/ImageTransformationFit.cs index caf25bbf8..fda7719e6 100644 --- a/src/management/Kontent.Ai.Management/Models/AssetRenditions/ImageTransformationFit.cs +++ b/src/management/Kontent.Ai.Management/Models/AssetRenditions/ImageTransformationFit.cs @@ -1,4 +1,3 @@ - namespace Kontent.Ai.Management.Models.AssetRenditions; /// diff --git a/src/management/Kontent.Ai.Management/Models/AssetRenditions/ImageTransformationMode.cs b/src/management/Kontent.Ai.Management/Models/AssetRenditions/ImageTransformationMode.cs index d43fceac6..0fffd1b8b 100644 --- a/src/management/Kontent.Ai.Management/Models/AssetRenditions/ImageTransformationMode.cs +++ b/src/management/Kontent.Ai.Management/Models/AssetRenditions/ImageTransformationMode.cs @@ -1,4 +1,3 @@ - namespace Kontent.Ai.Management.Models.AssetRenditions; /// diff --git a/src/management/Kontent.Ai.Management/Models/AssetRenditions/RectangleResizeTransformation.cs b/src/management/Kontent.Ai.Management/Models/AssetRenditions/RectangleResizeTransformation.cs index 3a7bc5e66..f8515e657 100644 --- a/src/management/Kontent.Ai.Management/Models/AssetRenditions/RectangleResizeTransformation.cs +++ b/src/management/Kontent.Ai.Management/Models/AssetRenditions/RectangleResizeTransformation.cs @@ -1,4 +1,3 @@ - namespace Kontent.Ai.Management.Models.AssetRenditions; /// diff --git a/src/management/Kontent.Ai.Management/Models/Assets/AssetCollectionReference.cs b/src/management/Kontent.Ai.Management/Models/Assets/AssetCollectionReference.cs index 9046fa023..2be29b30b 100644 --- a/src/management/Kontent.Ai.Management/Models/Assets/AssetCollectionReference.cs +++ b/src/management/Kontent.Ai.Management/Models/Assets/AssetCollectionReference.cs @@ -1,4 +1,3 @@ - namespace Kontent.Ai.Management.Models.Assets; /// diff --git a/src/management/Kontent.Ai.Management/Models/Assets/AssetDescription.cs b/src/management/Kontent.Ai.Management/Models/Assets/AssetDescription.cs index d441744e6..f193a757f 100644 --- a/src/management/Kontent.Ai.Management/Models/Assets/AssetDescription.cs +++ b/src/management/Kontent.Ai.Management/Models/Assets/AssetDescription.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.Assets; +namespace Kontent.Ai.Management.Models.Assets; /// /// Language-specific alt-text description for an asset. diff --git a/src/management/Kontent.Ai.Management/Models/Assets/FileReference.cs b/src/management/Kontent.Ai.Management/Models/Assets/FileReference.cs index 2d4a65eb5..f3688b121 100644 --- a/src/management/Kontent.Ai.Management/Models/Assets/FileReference.cs +++ b/src/management/Kontent.Ai.Management/Models/Assets/FileReference.cs @@ -1,4 +1,3 @@ - namespace Kontent.Ai.Management.Models.Assets; /// diff --git a/src/management/Kontent.Ai.Management/Models/Assets/FileReferenceType.cs b/src/management/Kontent.Ai.Management/Models/Assets/FileReferenceType.cs index b513d885b..71a90bc73 100644 --- a/src/management/Kontent.Ai.Management/Models/Assets/FileReferenceType.cs +++ b/src/management/Kontent.Ai.Management/Models/Assets/FileReferenceType.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.Assets; +namespace Kontent.Ai.Management.Models.Assets; /// /// Type of a file reference. Currently the API only uses . diff --git a/src/management/Kontent.Ai.Management/Models/Collections/CollectionCreateModel.cs b/src/management/Kontent.Ai.Management/Models/Collections/CollectionCreateModel.cs index 97578ceb3..5421a8a1b 100644 --- a/src/management/Kontent.Ai.Management/Models/Collections/CollectionCreateModel.cs +++ b/src/management/Kontent.Ai.Management/Models/Collections/CollectionCreateModel.cs @@ -1,4 +1,3 @@ - namespace Kontent.Ai.Management.Models.Collections; /// diff --git a/src/management/Kontent.Ai.Management/Models/Collections/Patch/CollectionAddIntoPatchModel.cs b/src/management/Kontent.Ai.Management/Models/Collections/Patch/CollectionAddIntoPatchModel.cs index 8a8374613..de27bdf72 100644 --- a/src/management/Kontent.Ai.Management/Models/Collections/Patch/CollectionAddIntoPatchModel.cs +++ b/src/management/Kontent.Ai.Management/Models/Collections/Patch/CollectionAddIntoPatchModel.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.Collections.Patch; +namespace Kontent.Ai.Management.Models.Collections.Patch; /// /// Patch operation that adds a new collection to the environment. diff --git a/src/management/Kontent.Ai.Management/Models/Collections/Patch/CollectionMovePatchModel.cs b/src/management/Kontent.Ai.Management/Models/Collections/Patch/CollectionMovePatchModel.cs index 2126604eb..6d47a195a 100644 --- a/src/management/Kontent.Ai.Management/Models/Collections/Patch/CollectionMovePatchModel.cs +++ b/src/management/Kontent.Ai.Management/Models/Collections/Patch/CollectionMovePatchModel.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.Collections.Patch; +namespace Kontent.Ai.Management.Models.Collections.Patch; /// /// Patch operation that changes a collection's position in the environment's ordered collection list. diff --git a/src/management/Kontent.Ai.Management/Models/Collections/Patch/CollectionOperationBaseModel.cs b/src/management/Kontent.Ai.Management/Models/Collections/Patch/CollectionOperationBaseModel.cs index 7399f12f2..2c81d5fc2 100644 --- a/src/management/Kontent.Ai.Management/Models/Collections/Patch/CollectionOperationBaseModel.cs +++ b/src/management/Kontent.Ai.Management/Models/Collections/Patch/CollectionOperationBaseModel.cs @@ -1,4 +1,3 @@ - namespace Kontent.Ai.Management.Models.Collections.Patch; /// diff --git a/src/management/Kontent.Ai.Management/Models/Collections/Patch/CollectionPropertyName.cs b/src/management/Kontent.Ai.Management/Models/Collections/Patch/CollectionPropertyName.cs index be2a02e62..c70f7f668 100644 --- a/src/management/Kontent.Ai.Management/Models/Collections/Patch/CollectionPropertyName.cs +++ b/src/management/Kontent.Ai.Management/Models/Collections/Patch/CollectionPropertyName.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.Collections.Patch; +namespace Kontent.Ai.Management.Models.Collections.Patch; /// /// Represents properties of the collection. diff --git a/src/management/Kontent.Ai.Management/Models/Collections/Patch/CollectionRemovePatchModel.cs b/src/management/Kontent.Ai.Management/Models/Collections/Patch/CollectionRemovePatchModel.cs index 55742f971..13b8f65fb 100644 --- a/src/management/Kontent.Ai.Management/Models/Collections/Patch/CollectionRemovePatchModel.cs +++ b/src/management/Kontent.Ai.Management/Models/Collections/Patch/CollectionRemovePatchModel.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.Collections.Patch; +namespace Kontent.Ai.Management.Models.Collections.Patch; /// /// Patch operation that deletes an existing collection. The collection must contain no items, and the default collection cannot be deleted. diff --git a/src/management/Kontent.Ai.Management/Models/Collections/Patch/CollectionReplacePatchModel.cs b/src/management/Kontent.Ai.Management/Models/Collections/Patch/CollectionReplacePatchModel.cs index a4b871595..ba6b9362d 100644 --- a/src/management/Kontent.Ai.Management/Models/Collections/Patch/CollectionReplacePatchModel.cs +++ b/src/management/Kontent.Ai.Management/Models/Collections/Patch/CollectionReplacePatchModel.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.Collections.Patch; +namespace Kontent.Ai.Management.Models.Collections.Patch; /// /// Patch operation that updates a property on an existing collection (today, only the name). diff --git a/src/management/Kontent.Ai.Management/Models/Content/AssetReference.cs b/src/management/Kontent.Ai.Management/Models/Content/AssetReference.cs index d6d420cc4..f7a557c99 100644 --- a/src/management/Kontent.Ai.Management/Models/Content/AssetReference.cs +++ b/src/management/Kontent.Ai.Management/Models/Content/AssetReference.cs @@ -1,4 +1,3 @@ - namespace Kontent.Ai.Management.Models.Content; /// diff --git a/src/management/Kontent.Ai.Management/Models/Content/Component.cs b/src/management/Kontent.Ai.Management/Models/Content/Component.cs index b2895057e..982857718 100644 --- a/src/management/Kontent.Ai.Management/Models/Content/Component.cs +++ b/src/management/Kontent.Ai.Management/Models/Content/Component.cs @@ -1,4 +1,3 @@ - namespace Kontent.Ai.Management.Models.Content; /// diff --git a/src/management/Kontent.Ai.Management/Models/Content/RichTextValue.cs b/src/management/Kontent.Ai.Management/Models/Content/RichTextValue.cs index 27a799a97..0a1ab0489 100644 --- a/src/management/Kontent.Ai.Management/Models/Content/RichTextValue.cs +++ b/src/management/Kontent.Ai.Management/Models/Content/RichTextValue.cs @@ -1,4 +1,3 @@ - namespace Kontent.Ai.Management.Models.Content; /// diff --git a/src/management/Kontent.Ai.Management/Models/CustomApps/CustomAppDisplayMode.cs b/src/management/Kontent.Ai.Management/Models/CustomApps/CustomAppDisplayMode.cs index 7e0608913..ecda4bd4e 100644 --- a/src/management/Kontent.Ai.Management/Models/CustomApps/CustomAppDisplayMode.cs +++ b/src/management/Kontent.Ai.Management/Models/CustomApps/CustomAppDisplayMode.cs @@ -1,4 +1,3 @@ - namespace Kontent.Ai.Management.Models.CustomApps; /// diff --git a/src/management/Kontent.Ai.Management/Models/CustomApps/Patch/CustomAppPropertyName.cs b/src/management/Kontent.Ai.Management/Models/CustomApps/Patch/CustomAppPropertyName.cs index ae4ed09d3..b7362f57d 100644 --- a/src/management/Kontent.Ai.Management/Models/CustomApps/Patch/CustomAppPropertyName.cs +++ b/src/management/Kontent.Ai.Management/Models/CustomApps/Patch/CustomAppPropertyName.cs @@ -1,4 +1,3 @@ - namespace Kontent.Ai.Management.Models.CustomApps.Patch; /// diff --git a/src/management/Kontent.Ai.Management/Models/EnvironmentValidation/AsyncValidationTaskIssueType.cs b/src/management/Kontent.Ai.Management/Models/EnvironmentValidation/AsyncValidationTaskIssueType.cs index 4bf8755a5..08db7ef2f 100644 --- a/src/management/Kontent.Ai.Management/Models/EnvironmentValidation/AsyncValidationTaskIssueType.cs +++ b/src/management/Kontent.Ai.Management/Models/EnvironmentValidation/AsyncValidationTaskIssueType.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.EnvironmentValidation; +namespace Kontent.Ai.Management.Models.EnvironmentValidation; /// /// The type of the async validation task issue. diff --git a/src/management/Kontent.Ai.Management/Models/EnvironmentValidation/AsyncValidationTaskResult.cs b/src/management/Kontent.Ai.Management/Models/EnvironmentValidation/AsyncValidationTaskResult.cs index e994671d6..adb59238f 100644 --- a/src/management/Kontent.Ai.Management/Models/EnvironmentValidation/AsyncValidationTaskResult.cs +++ b/src/management/Kontent.Ai.Management/Models/EnvironmentValidation/AsyncValidationTaskResult.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.EnvironmentValidation; +namespace Kontent.Ai.Management.Models.EnvironmentValidation; /// /// The result of the async validation task. diff --git a/src/management/Kontent.Ai.Management/Models/EnvironmentValidation/AsyncValidationTaskStatus.cs b/src/management/Kontent.Ai.Management/Models/EnvironmentValidation/AsyncValidationTaskStatus.cs index c0ce7a3ce..e199f12fa 100644 --- a/src/management/Kontent.Ai.Management/Models/EnvironmentValidation/AsyncValidationTaskStatus.cs +++ b/src/management/Kontent.Ai.Management/Models/EnvironmentValidation/AsyncValidationTaskStatus.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.EnvironmentValidation; +namespace Kontent.Ai.Management.Models.EnvironmentValidation; /// /// The status of the async validation task. diff --git a/src/management/Kontent.Ai.Management/Models/Environments/CloningState.cs b/src/management/Kontent.Ai.Management/Models/Environments/CloningState.cs index 95061431c..9817189fd 100644 --- a/src/management/Kontent.Ai.Management/Models/Environments/CloningState.cs +++ b/src/management/Kontent.Ai.Management/Models/Environments/CloningState.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.Environments; +namespace Kontent.Ai.Management.Models.Environments; /// /// Represents the state on environment cloning. @@ -19,7 +18,7 @@ public enum CloningState Failed, /// - /// Environment cloning is succesfully done. + /// Environment cloning is successfully done. /// [JsonStringEnumMemberName("done")] Done diff --git a/src/management/Kontent.Ai.Management/Models/Environments/CopyDataOptions.cs b/src/management/Kontent.Ai.Management/Models/Environments/CopyDataOptions.cs index 7fdf3a980..f3f063cff 100644 --- a/src/management/Kontent.Ai.Management/Models/Environments/CopyDataOptions.cs +++ b/src/management/Kontent.Ai.Management/Models/Environments/CopyDataOptions.cs @@ -1,4 +1,3 @@ - namespace Kontent.Ai.Management.Models.Environments; /// diff --git a/src/management/Kontent.Ai.Management/Models/Environments/EnvironmentCloningStateModel.cs b/src/management/Kontent.Ai.Management/Models/Environments/EnvironmentCloningStateModel.cs index b88657904..cfcfb66c2 100644 --- a/src/management/Kontent.Ai.Management/Models/Environments/EnvironmentCloningStateModel.cs +++ b/src/management/Kontent.Ai.Management/Models/Environments/EnvironmentCloningStateModel.cs @@ -1,4 +1,3 @@ - namespace Kontent.Ai.Management.Models.Environments; /// diff --git a/src/management/Kontent.Ai.Management/Models/Environments/Patch/EnvironmentOperationBaseModel.cs b/src/management/Kontent.Ai.Management/Models/Environments/Patch/EnvironmentOperationBaseModel.cs index 2e7a79f5f..dd60a9e45 100644 --- a/src/management/Kontent.Ai.Management/Models/Environments/Patch/EnvironmentOperationBaseModel.cs +++ b/src/management/Kontent.Ai.Management/Models/Environments/Patch/EnvironmentOperationBaseModel.cs @@ -1,4 +1,3 @@ - namespace Kontent.Ai.Management.Models.Environments.Patch; /// diff --git a/src/management/Kontent.Ai.Management/Models/Environments/Patch/EnvironmentRenamePatchModel.cs b/src/management/Kontent.Ai.Management/Models/Environments/Patch/EnvironmentRenamePatchModel.cs index 387ff7e77..265cff10f 100644 --- a/src/management/Kontent.Ai.Management/Models/Environments/Patch/EnvironmentRenamePatchModel.cs +++ b/src/management/Kontent.Ai.Management/Models/Environments/Patch/EnvironmentRenamePatchModel.cs @@ -1,4 +1,3 @@ - namespace Kontent.Ai.Management.Models.Environments.Patch; /// diff --git a/src/management/Kontent.Ai.Management/Models/ItemWithVariant/ItemWithVariantFilterResultModel.cs b/src/management/Kontent.Ai.Management/Models/ItemWithVariant/ItemWithVariantFilterResultModel.cs index 1cc837ac3..2472d3ff7 100644 --- a/src/management/Kontent.Ai.Management/Models/ItemWithVariant/ItemWithVariantFilterResultModel.cs +++ b/src/management/Kontent.Ai.Management/Models/ItemWithVariant/ItemWithVariantFilterResultModel.cs @@ -1,4 +1,3 @@ - namespace Kontent.Ai.Management.Models.ItemWithVariant; /// diff --git a/src/management/Kontent.Ai.Management/Models/ItemWithVariant/VariantIdentifierModel.cs b/src/management/Kontent.Ai.Management/Models/ItemWithVariant/VariantIdentifierModel.cs index 37aff7989..48fb5bb9b 100644 --- a/src/management/Kontent.Ai.Management/Models/ItemWithVariant/VariantIdentifierModel.cs +++ b/src/management/Kontent.Ai.Management/Models/ItemWithVariant/VariantIdentifierModel.cs @@ -1,4 +1,3 @@ - namespace Kontent.Ai.Management.Models.ItemWithVariant; /// diff --git a/src/management/Kontent.Ai.Management/Models/Items/ContentItemCreateModel.cs b/src/management/Kontent.Ai.Management/Models/Items/ContentItemCreateModel.cs index d8dab8d7d..0103e1299 100644 --- a/src/management/Kontent.Ai.Management/Models/Items/ContentItemCreateModel.cs +++ b/src/management/Kontent.Ai.Management/Models/Items/ContentItemCreateModel.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.Items; +namespace Kontent.Ai.Management.Models.Items; /// /// Request payload for creating a new content item via POST /items. diff --git a/src/management/Kontent.Ai.Management/Models/Languages/Patch/LanguagePropertyName.cs b/src/management/Kontent.Ai.Management/Models/Languages/Patch/LanguagePropertyName.cs index ba934a06f..d0c56929f 100644 --- a/src/management/Kontent.Ai.Management/Models/Languages/Patch/LanguagePropertyName.cs +++ b/src/management/Kontent.Ai.Management/Models/Languages/Patch/LanguagePropertyName.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.Languages.Patch; +namespace Kontent.Ai.Management.Models.Languages.Patch; /// /// Represents properties that can be modified on the content language. diff --git a/src/management/Kontent.Ai.Management/Models/Shared/AssetFolder.cs b/src/management/Kontent.Ai.Management/Models/Shared/AssetFolder.cs index c700a08fc..63d6c9508 100644 --- a/src/management/Kontent.Ai.Management/Models/Shared/AssetFolder.cs +++ b/src/management/Kontent.Ai.Management/Models/Shared/AssetFolder.cs @@ -1,4 +1,3 @@ - namespace Kontent.Ai.Management.Models.Shared; /// diff --git a/src/management/Kontent.Ai.Management/Models/Shared/PaginationResponseModel.cs b/src/management/Kontent.Ai.Management/Models/Shared/PaginationResponseModel.cs index 905df0f18..22d941017 100644 --- a/src/management/Kontent.Ai.Management/Models/Shared/PaginationResponseModel.cs +++ b/src/management/Kontent.Ai.Management/Models/Shared/PaginationResponseModel.cs @@ -1,4 +1,3 @@ - namespace Kontent.Ai.Management.Models.Shared; internal sealed record PaginationResponseModel diff --git a/src/management/Kontent.Ai.Management/Models/Spaces/Patch/SpacePropertyName.cs b/src/management/Kontent.Ai.Management/Models/Spaces/Patch/SpacePropertyName.cs index 56b1da21a..708650e44 100644 --- a/src/management/Kontent.Ai.Management/Models/Spaces/Patch/SpacePropertyName.cs +++ b/src/management/Kontent.Ai.Management/Models/Spaces/Patch/SpacePropertyName.cs @@ -1,4 +1,3 @@ - namespace Kontent.Ai.Management.Models.Spaces.Patch; /// diff --git a/src/management/Kontent.Ai.Management/Models/TaxonomyGroups/Patch/TaxonomyGroupAddIntoPatchModel.cs b/src/management/Kontent.Ai.Management/Models/TaxonomyGroups/Patch/TaxonomyGroupAddIntoPatchModel.cs index d94860677..f7bf1d23e 100644 --- a/src/management/Kontent.Ai.Management/Models/TaxonomyGroups/Patch/TaxonomyGroupAddIntoPatchModel.cs +++ b/src/management/Kontent.Ai.Management/Models/TaxonomyGroups/Patch/TaxonomyGroupAddIntoPatchModel.cs @@ -1,13 +1,19 @@ namespace Kontent.Ai.Management.Models.TaxonomyGroups.Patch; /// -/// addInto operation. Inserts a new term into the taxonomy group. points at the parent term (or is null to add at the root). +/// addInto operation. Inserts a new term into the taxonomy group. points at the parent term (or is null to add at the root). /// public sealed record TaxonomyGroupAddIntoPatchModel : TaxonomyGroupOperationBaseModel { /// public override string Op => "addInto"; + /// + /// Reference to the parent term that receives the new child; omit to add at the root of the taxonomy group. + /// + [JsonPropertyName("reference")] + public Reference? Reference { get; init; } + /// /// New term to insert. /// diff --git a/src/management/Kontent.Ai.Management/Models/TaxonomyGroups/Patch/TaxonomyGroupMovePatchModel.cs b/src/management/Kontent.Ai.Management/Models/TaxonomyGroups/Patch/TaxonomyGroupMovePatchModel.cs index a79022c46..33c8217e5 100644 --- a/src/management/Kontent.Ai.Management/Models/TaxonomyGroups/Patch/TaxonomyGroupMovePatchModel.cs +++ b/src/management/Kontent.Ai.Management/Models/TaxonomyGroups/Patch/TaxonomyGroupMovePatchModel.cs @@ -1,13 +1,19 @@ namespace Kontent.Ai.Management.Models.TaxonomyGroups.Patch; /// -/// move operation. Moves the taxonomy term identified by to a new position. The API requires exactly one of , , or ; sending none (or more than one) returns 400. re-parents the term to a new container. +/// move operation. Moves the taxonomy term identified by to a new position. The API requires exactly one of , , or ; sending none (or more than one) returns 400. re-parents the term to a new container. /// public sealed record TaxonomyGroupMovePatchModel : TaxonomyGroupOperationBaseModel { /// public override string Op => "move"; + /// + /// Reference to the taxonomy term to move. + /// + [JsonPropertyName("reference")] + public required Reference Reference { get; init; } + /// /// Position the moved term before this sibling. Mutually exclusive with and . /// diff --git a/src/management/Kontent.Ai.Management/Models/TaxonomyGroups/Patch/TaxonomyGroupOperationBaseModel.cs b/src/management/Kontent.Ai.Management/Models/TaxonomyGroups/Patch/TaxonomyGroupOperationBaseModel.cs index 29c341d5b..5542b3312 100644 --- a/src/management/Kontent.Ai.Management/Models/TaxonomyGroups/Patch/TaxonomyGroupOperationBaseModel.cs +++ b/src/management/Kontent.Ai.Management/Models/TaxonomyGroups/Patch/TaxonomyGroupOperationBaseModel.cs @@ -10,10 +10,4 @@ public abstract record TaxonomyGroupOperationBaseModel /// [JsonPropertyName("op")] public abstract string Op { get; } - - /// - /// Reference to the target. Required for replace, move, and remove. On addInto it points at the parent term that should receive the new child; omit it to add at the root of the taxonomy group. - /// - [JsonPropertyName("reference")] - public Reference? Reference { get; init; } } diff --git a/src/management/Kontent.Ai.Management/Models/TaxonomyGroups/Patch/TaxonomyGroupPropertyName.cs b/src/management/Kontent.Ai.Management/Models/TaxonomyGroups/Patch/TaxonomyGroupPropertyName.cs index 04654f155..b179e23f3 100644 --- a/src/management/Kontent.Ai.Management/Models/TaxonomyGroups/Patch/TaxonomyGroupPropertyName.cs +++ b/src/management/Kontent.Ai.Management/Models/TaxonomyGroups/Patch/TaxonomyGroupPropertyName.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.TaxonomyGroups.Patch; +namespace Kontent.Ai.Management.Models.TaxonomyGroups.Patch; /// /// Represents enum of properties that can be replaced in the taxonomy group. diff --git a/src/management/Kontent.Ai.Management/Models/TaxonomyGroups/Patch/TaxonomyGroupRemovePatchModel.cs b/src/management/Kontent.Ai.Management/Models/TaxonomyGroups/Patch/TaxonomyGroupRemovePatchModel.cs index ff666ed21..ac275cd4f 100644 --- a/src/management/Kontent.Ai.Management/Models/TaxonomyGroups/Patch/TaxonomyGroupRemovePatchModel.cs +++ b/src/management/Kontent.Ai.Management/Models/TaxonomyGroups/Patch/TaxonomyGroupRemovePatchModel.cs @@ -1,10 +1,16 @@ namespace Kontent.Ai.Management.Models.TaxonomyGroups.Patch; /// -/// remove operation. Removes the taxonomy term identified by . +/// remove operation. Removes the taxonomy term identified by . /// public sealed record TaxonomyGroupRemovePatchModel : TaxonomyGroupOperationBaseModel { /// public override string Op => "remove"; + + /// + /// Reference to the taxonomy term to remove. + /// + [JsonPropertyName("reference")] + public required Reference Reference { get; init; } } diff --git a/src/management/Kontent.Ai.Management/Models/TaxonomyGroups/Patch/TaxonomyGroupReplacePatchModel.cs b/src/management/Kontent.Ai.Management/Models/TaxonomyGroups/Patch/TaxonomyGroupReplacePatchModel.cs index 0f0c65802..12af6286d 100644 --- a/src/management/Kontent.Ai.Management/Models/TaxonomyGroups/Patch/TaxonomyGroupReplacePatchModel.cs +++ b/src/management/Kontent.Ai.Management/Models/TaxonomyGroups/Patch/TaxonomyGroupReplacePatchModel.cs @@ -1,13 +1,19 @@ namespace Kontent.Ai.Management.Models.TaxonomyGroups.Patch; /// -/// replace operation. Replaces a property of the taxonomy group or one of its terms. The targeted object is identified by ; selects which of its properties to replace. +/// replace operation. Replaces a property of the taxonomy group or one of its terms. The targeted object is identified by ; selects which of its properties to replace. /// public sealed record TaxonomyGroupReplacePatchModel : TaxonomyGroupOperationBaseModel { /// public override string Op => "replace"; + /// + /// Reference to the taxonomy group or term whose property is replaced. + /// + [JsonPropertyName("reference")] + public required Reference Reference { get; init; } + /// /// Property to replace. Valid values are , , and . /// diff --git a/src/management/Kontent.Ai.Management/Models/Types/Elements/ElementMetadataType.cs b/src/management/Kontent.Ai.Management/Models/Types/Elements/ElementMetadataType.cs index f1f6941df..4e16915be 100644 --- a/src/management/Kontent.Ai.Management/Models/Types/Elements/ElementMetadataType.cs +++ b/src/management/Kontent.Ai.Management/Models/Types/Elements/ElementMetadataType.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.Types.Elements; +namespace Kontent.Ai.Management.Models.Types.Elements; /// /// Enum of all possible element types in content types. diff --git a/src/management/Kontent.Ai.Management/Models/Types/Elements/FileType.cs b/src/management/Kontent.Ai.Management/Models/Types/Elements/FileType.cs index fbeed19fd..e017e16e1 100644 --- a/src/management/Kontent.Ai.Management/Models/Types/Elements/FileType.cs +++ b/src/management/Kontent.Ai.Management/Models/Types/Elements/FileType.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.Types.Elements; +namespace Kontent.Ai.Management.Models.Types.Elements; /// /// Represents the allowed file types for the asset element in content types. diff --git a/src/management/Kontent.Ai.Management/Models/Types/Elements/MultipleChoiceMode.cs b/src/management/Kontent.Ai.Management/Models/Types/Elements/MultipleChoiceMode.cs index ad9a0be36..df5cb4087 100644 --- a/src/management/Kontent.Ai.Management/Models/Types/Elements/MultipleChoiceMode.cs +++ b/src/management/Kontent.Ai.Management/Models/Types/Elements/MultipleChoiceMode.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.Types.Elements; +namespace Kontent.Ai.Management.Models.Types.Elements; /// /// Defines whether the multiple-choice element acts as a single choice (shown as radio buttons in the UI) or multiple-choice (shown as checkboxes in the UI). diff --git a/src/management/Kontent.Ai.Management/Models/Types/Elements/RichTextBlockType.cs b/src/management/Kontent.Ai.Management/Models/Types/Elements/RichTextBlockType.cs index d109b4362..2a5e6cfb2 100644 --- a/src/management/Kontent.Ai.Management/Models/Types/Elements/RichTextBlockType.cs +++ b/src/management/Kontent.Ai.Management/Models/Types/Elements/RichTextBlockType.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.Types.Elements; +namespace Kontent.Ai.Management.Models.Types.Elements; /// /// Specifies which blocks are allowed inside your rich text element. diff --git a/src/management/Kontent.Ai.Management/Models/Types/Elements/RichTextFormattingType.cs b/src/management/Kontent.Ai.Management/Models/Types/Elements/RichTextFormattingType.cs index 5d220f513..478e32c8c 100644 --- a/src/management/Kontent.Ai.Management/Models/Types/Elements/RichTextFormattingType.cs +++ b/src/management/Kontent.Ai.Management/Models/Types/Elements/RichTextFormattingType.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.Types.Elements; +namespace Kontent.Ai.Management.Models.Types.Elements; /// /// Specifies which text formatting is allowed in a rich text element. To allow all formatting, leave the array empty. diff --git a/src/management/Kontent.Ai.Management/Models/Types/Elements/RichTextTableBlockType.cs b/src/management/Kontent.Ai.Management/Models/Types/Elements/RichTextTableBlockType.cs index b6d4fa5d9..d97424870 100644 --- a/src/management/Kontent.Ai.Management/Models/Types/Elements/RichTextTableBlockType.cs +++ b/src/management/Kontent.Ai.Management/Models/Types/Elements/RichTextTableBlockType.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.Types.Elements; +namespace Kontent.Ai.Management.Models.Types.Elements; /// /// Represents blocks types that can be used inside tables in your rich text element. diff --git a/src/management/Kontent.Ai.Management/Models/Types/Elements/RichTextTextBlockType.cs b/src/management/Kontent.Ai.Management/Models/Types/Elements/RichTextTextBlockType.cs index 436263847..9a32c24db 100644 --- a/src/management/Kontent.Ai.Management/Models/Types/Elements/RichTextTextBlockType.cs +++ b/src/management/Kontent.Ai.Management/Models/Types/Elements/RichTextTextBlockType.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.Types.Elements; +namespace Kontent.Ai.Management.Models.Types.Elements; /// /// Represents block types that can be used inside your rich text element. @@ -7,7 +6,7 @@ namespace Kontent.Ai.Management.Models.Types.Elements; public enum RichTextTextBlockType { /// - /// OrderList + /// Ordered list /// [JsonStringEnumMemberName("ordered-list")] OrderedList, diff --git a/src/management/Kontent.Ai.Management/Models/Types/Elements/TextLengthLimitType.cs b/src/management/Kontent.Ai.Management/Models/Types/Elements/TextLengthLimitType.cs index e502a04c3..498aba2ae 100644 --- a/src/management/Kontent.Ai.Management/Models/Types/Elements/TextLengthLimitType.cs +++ b/src/management/Kontent.Ai.Management/Models/Types/Elements/TextLengthLimitType.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.Types.Elements; +namespace Kontent.Ai.Management.Models.Types.Elements; /// /// Determines whether the maximum_text_length applies to characters or words. diff --git a/src/management/Kontent.Ai.Management/Models/Types/LimitType.cs b/src/management/Kontent.Ai.Management/Models/Types/LimitType.cs index 5ad6b841a..94a88c665 100644 --- a/src/management/Kontent.Ai.Management/Models/Types/LimitType.cs +++ b/src/management/Kontent.Ai.Management/Models/Types/LimitType.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.Types; +namespace Kontent.Ai.Management.Models.Types; /// /// Defines how to apply the limitation. diff --git a/src/management/Kontent.Ai.Management/Models/VariantFilter/VariantFilterCompletionStatus.cs b/src/management/Kontent.Ai.Management/Models/VariantFilter/VariantFilterCompletionStatus.cs index d475a0e66..13c0637dd 100644 --- a/src/management/Kontent.Ai.Management/Models/VariantFilter/VariantFilterCompletionStatus.cs +++ b/src/management/Kontent.Ai.Management/Models/VariantFilter/VariantFilterCompletionStatus.cs @@ -1,4 +1,3 @@ - namespace Kontent.Ai.Management.Models.VariantFilter; /// diff --git a/src/management/Kontent.Ai.Management/Models/VariantFilter/VariantFilterOrderDirection.cs b/src/management/Kontent.Ai.Management/Models/VariantFilter/VariantFilterOrderDirection.cs index fdeca47b8..19cdf719e 100644 --- a/src/management/Kontent.Ai.Management/Models/VariantFilter/VariantFilterOrderDirection.cs +++ b/src/management/Kontent.Ai.Management/Models/VariantFilter/VariantFilterOrderDirection.cs @@ -1,4 +1,3 @@ - namespace Kontent.Ai.Management.Models.VariantFilter; /// diff --git a/src/management/Kontent.Ai.Management/Models/VariantFilter/VariantFilterOrderModel.cs b/src/management/Kontent.Ai.Management/Models/VariantFilter/VariantFilterOrderModel.cs index f2dc037b2..4e7ed0a0c 100644 --- a/src/management/Kontent.Ai.Management/Models/VariantFilter/VariantFilterOrderModel.cs +++ b/src/management/Kontent.Ai.Management/Models/VariantFilter/VariantFilterOrderModel.cs @@ -1,4 +1,3 @@ - namespace Kontent.Ai.Management.Models.VariantFilter; /// diff --git a/src/management/Kontent.Ai.Management/Models/VariantFilter/VariantFilterPublishingState.cs b/src/management/Kontent.Ai.Management/Models/VariantFilter/VariantFilterPublishingState.cs index 89d7613ba..a680af885 100644 --- a/src/management/Kontent.Ai.Management/Models/VariantFilter/VariantFilterPublishingState.cs +++ b/src/management/Kontent.Ai.Management/Models/VariantFilter/VariantFilterPublishingState.cs @@ -1,4 +1,3 @@ - namespace Kontent.Ai.Management.Models.VariantFilter; /// diff --git a/src/management/Kontent.Ai.Management/Models/Webhooks/Triggers/Asset/AssetAction.cs b/src/management/Kontent.Ai.Management/Models/Webhooks/Triggers/Asset/AssetAction.cs index 3d7cb542a..16d0d7147 100644 --- a/src/management/Kontent.Ai.Management/Models/Webhooks/Triggers/Asset/AssetAction.cs +++ b/src/management/Kontent.Ai.Management/Models/Webhooks/Triggers/Asset/AssetAction.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.Webhooks.Triggers.Asset; +namespace Kontent.Ai.Management.Models.Webhooks.Triggers.Asset; /// /// Represents asset actions. diff --git a/src/management/Kontent.Ai.Management/Models/Webhooks/Triggers/ContentItem/ContentItemAction.cs b/src/management/Kontent.Ai.Management/Models/Webhooks/Triggers/ContentItem/ContentItemAction.cs index 5943cdf41..67af98576 100644 --- a/src/management/Kontent.Ai.Management/Models/Webhooks/Triggers/ContentItem/ContentItemAction.cs +++ b/src/management/Kontent.Ai.Management/Models/Webhooks/Triggers/ContentItem/ContentItemAction.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.Webhooks.Triggers.ContentItem; +namespace Kontent.Ai.Management.Models.Webhooks.Triggers.ContentItem; /// /// Represents content item actions. diff --git a/src/management/Kontent.Ai.Management/Models/Webhooks/Triggers/ContentType/ContentTypeAction.cs b/src/management/Kontent.Ai.Management/Models/Webhooks/Triggers/ContentType/ContentTypeAction.cs index 51301612f..a9d72445b 100644 --- a/src/management/Kontent.Ai.Management/Models/Webhooks/Triggers/ContentType/ContentTypeAction.cs +++ b/src/management/Kontent.Ai.Management/Models/Webhooks/Triggers/ContentType/ContentTypeAction.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.Webhooks.Triggers.ContentType; +namespace Kontent.Ai.Management.Models.Webhooks.Triggers.ContentType; /// /// Represents content type actions. diff --git a/src/management/Kontent.Ai.Management/Models/Webhooks/Triggers/DeliverySlot.cs b/src/management/Kontent.Ai.Management/Models/Webhooks/Triggers/DeliverySlot.cs index 9fd8fd35a..a9ee4e2b8 100644 --- a/src/management/Kontent.Ai.Management/Models/Webhooks/Triggers/DeliverySlot.cs +++ b/src/management/Kontent.Ai.Management/Models/Webhooks/Triggers/DeliverySlot.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.Webhooks.Triggers; +namespace Kontent.Ai.Management.Models.Webhooks.Triggers; /// /// Represents the delivery slot. diff --git a/src/management/Kontent.Ai.Management/Models/Webhooks/Triggers/Language/LanguageAction.cs b/src/management/Kontent.Ai.Management/Models/Webhooks/Triggers/Language/LanguageAction.cs index 705f400c8..175192336 100644 --- a/src/management/Kontent.Ai.Management/Models/Webhooks/Triggers/Language/LanguageAction.cs +++ b/src/management/Kontent.Ai.Management/Models/Webhooks/Triggers/Language/LanguageAction.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.Webhooks.Triggers.Language; +namespace Kontent.Ai.Management.Models.Webhooks.Triggers.Language; /// /// Represents a language action. diff --git a/src/management/Kontent.Ai.Management/Models/Webhooks/Triggers/Taxonomy/TaxonomyAction.cs b/src/management/Kontent.Ai.Management/Models/Webhooks/Triggers/Taxonomy/TaxonomyAction.cs index d4aa9d775..a216adbd8 100644 --- a/src/management/Kontent.Ai.Management/Models/Webhooks/Triggers/Taxonomy/TaxonomyAction.cs +++ b/src/management/Kontent.Ai.Management/Models/Webhooks/Triggers/Taxonomy/TaxonomyAction.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.Webhooks.Triggers.Taxonomy; +namespace Kontent.Ai.Management.Models.Webhooks.Triggers.Taxonomy; /// /// Represents taxonomy actions. diff --git a/src/management/Kontent.Ai.Management/Models/Webhooks/Triggers/WebhookEvents.cs b/src/management/Kontent.Ai.Management/Models/Webhooks/Triggers/WebhookEvents.cs index fd9631975..018b2dd61 100644 --- a/src/management/Kontent.Ai.Management/Models/Webhooks/Triggers/WebhookEvents.cs +++ b/src/management/Kontent.Ai.Management/Models/Webhooks/Triggers/WebhookEvents.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.Webhooks.Triggers; +namespace Kontent.Ai.Management.Models.Webhooks.Triggers; /// /// Specifies whether all available events can trigger the webhook or only the specified ones. diff --git a/src/management/Kontent.Ai.Management/Models/Webhooks/WebhookHealthStatus.cs b/src/management/Kontent.Ai.Management/Models/Webhooks/WebhookHealthStatus.cs index 43e6bd1c6..956e31f39 100644 --- a/src/management/Kontent.Ai.Management/Models/Webhooks/WebhookHealthStatus.cs +++ b/src/management/Kontent.Ai.Management/Models/Webhooks/WebhookHealthStatus.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.Webhooks; +namespace Kontent.Ai.Management.Models.Webhooks; /// /// Webhook health status. diff --git a/src/management/Kontent.Ai.Management/Models/Workflow/WorkflowStepColor.cs b/src/management/Kontent.Ai.Management/Models/Workflow/WorkflowStepColor.cs index 9d3f39cf4..64827b016 100644 --- a/src/management/Kontent.Ai.Management/Models/Workflow/WorkflowStepColor.cs +++ b/src/management/Kontent.Ai.Management/Models/Workflow/WorkflowStepColor.cs @@ -1,5 +1,4 @@ - -namespace Kontent.Ai.Management.Models.Workflow; +namespace Kontent.Ai.Management.Models.Workflow; /// /// Workflow step colors. diff --git a/src/management/Kontent.Ai.Management/ValidationError.cs b/src/management/Kontent.Ai.Management/ValidationError.cs index 45f7b621c..daa1e82d3 100644 --- a/src/management/Kontent.Ai.Management/ValidationError.cs +++ b/src/management/Kontent.Ai.Management/ValidationError.cs @@ -1,4 +1,3 @@ - namespace Kontent.Ai.Management; /// diff --git a/src/management/README.md b/src/management/README.md index 6a8f9c566..bb2b3b702 100644 --- a/src/management/README.md +++ b/src/management/README.md @@ -122,7 +122,7 @@ A standalone client owns its `HttpClient` instances — dispose it when you are ### Fluent Builder -When you are **not** using DI but still need to customize the resilience pipeline or Refit settings, use `ManagementClientBuilder`: +When you are **not** using DI but still need to customize the resilience pipeline, use `ManagementClientBuilder`: ```csharp await using var client = ManagementClientBuilder @@ -257,7 +257,10 @@ SubscriptionId is not configured. Set ManagementOptions.SubscriptionId to call s ## The Result Pattern -Every `IManagementClient` method returns an `IManagementResult` (for void operations) or an `IManagementResult` (for operations that yield a value). The SDK **does not throw** on Management API `4xx`/`5xx` responses — inspect the result instead. Network-level and serialization failures still propagate as exceptions. +Every `IManagementClient` method returns an `IManagementResult` (for void operations) or an `IManagementResult` (for operations that yield a value). The SDK **does not throw** when a call fails — inspect the result instead. That covers Management API `4xx`/`5xx` responses, a transport failure that never reached the server, and a response whose body could not be read; where an exception caused the failure, `Error.Exception` carries it. + +> [!IMPORTANT] +> Do not write `try`/`catch` around a call expecting to catch a failed request — it will not fire. `IsSuccess` is the check. ```csharp var result = await client.CreateContentItemAsync(new ContentItemCreateModel @@ -349,7 +352,16 @@ if (!result.IsSuccess && result.Error?.ErrorCode == ManagementErrorCodes.Publish `ManagementErrorCodes` is a curated set of the codes callers commonly act on — variant workflow-state conflicts, duplicate external IDs, concurrency, and rate limits. The codes are not unique (the API reuses some across unrelated conditions), so inspect `Message` as well when the distinction matters. > [!IMPORTANT] -> Exceptions are reserved for **programmer errors** (for example, a `null` argument), **invalid configuration**, and **network/serialization failures** — not for API errors. A `404` or an API validation rejection comes back as `IsSuccess == false`, never as a thrown exception. +> A failed call is a result, not an exception. A `404`, an API validation rejection, an unreachable host and an unreadable response body all come back as `IsSuccess == false`. +> +> Four things still throw: +> +> - **Cancellation** — a cancelled call throws `OperationCanceledException`, so `Task.IsCanceled` and cancellation handlers behave normally. (An expired timeout is *not* cancellation: the request was sent and may have been applied, so it comes back as a failed result.) +> - **Programmer errors** — a `null` argument throws `ArgumentNullException`. +> - **Invalid configuration** — validated when the client is built or registered. +> - **`EnsureSuccess()`** — the opt-in conversion of a failed result into a `ManagementException`. +> +> The strongly-typed language-variant overloads add one more: projecting a response onto a generated record that no longer matches the content type throws, because that is a mismatch between your model and the environment rather than an outcome of the call. ## Identifiers diff --git a/src/management/docs/release-notes-9.0.0-beta-1.md b/src/management/docs/release-notes-9.0.0-beta-1.md index f1bfbc08f..8cc5370d0 100644 --- a/src/management/docs/release-notes-9.0.0-beta-1.md +++ b/src/management/docs/release-notes-9.0.0-beta-1.md @@ -10,7 +10,7 @@ First public beta of the **ground-up modernized Management SDK**, targeting the ## Highlights -- **Result pattern instead of exceptions.** Methods no longer throw `ManagementException` on `4xx`/`5xx`. Every call returns `IManagementResult` / `IManagementResult` — inspect `IsSuccess`, `Value`, `Error`, `StatusCode`, `RequestUrl`. Opt back into throwing with `EnsureSuccess()`, or use `TryGetValue(out var value)`. Branch on specific failures via the `ManagementErrorCodes` catalog. Only programmer errors, invalid configuration, and network/serialization failures still throw. +- **Result pattern instead of exceptions.** Methods no longer throw `ManagementException` on `4xx`/`5xx`. Every call returns `IManagementResult` / `IManagementResult` — inspect `IsSuccess`, `Value`, `Error`, `StatusCode`, `RequestUrl`. Opt back into throwing with `EnsureSuccess()`, or use `TryGetValue(out var value)`. Branch on specific failures via the `ManagementErrorCodes` catalog. Only cancellation, programmer errors and invalid configuration still throw — a transport failure or an unreadable response body is a failed result like any other. - **Three ways to create a client.** The `new ManagementClient(options)` constructor still works (now `IDisposable` / `IAsyncDisposable` — `await using` it). New: `services.AddManagementClient(...)` for DI (with keyed/named clients via `IManagementClientFactory`) and a fluent `ManagementClientBuilder` for non-DI customization. - **Materialized listings.** `List…Async` walks every continuation page, merges them, and returns the whole set in one result (all-or-nothing — a failed page short-circuits, never a silently truncated set). Large listings (content items, assets, items-with-variants) also expose a streaming `Enumerate…PagesAsync` that yields one page at a time and lets you stop early. - **Immutable, strongly-typed models.** Generated models are records; an element property *is* its value (`string Title`, `decimal? Price`, `IEnumerable` for linked items) or a small companion record — `RichTextValue`, `DateTimeValue`, `UrlSlugValue`, `CustomValue` — each with an implicit conversion for the common case. Edit with a `with` expression. Date/time properties take a `DateTimeOffset` (stored as a UTC instant). diff --git a/src/management/docs/upgrade-guide.md b/src/management/docs/upgrade-guide.md index 81fea5054..6213ad603 100644 --- a/src/management/docs/upgrade-guide.md +++ b/src/management/docs/upgrade-guide.md @@ -142,7 +142,7 @@ The endpoint override was renamed **`EndpointV2` → `Endpoint`** (the SDK appen ## 2. Response Handling: Exceptions → Result Pattern -This is the single largest break. The SDK **no longer throws** on Management API errors (`4xx`/`5xx`). Every method returns a result you inspect. Network-level and serialization failures still propagate as exceptions. +This is the single largest break. The SDK **no longer throws** when a call fails. Every method returns a result you inspect — and that covers Management API errors (`4xx`/`5xx`), a transport failure that never reached the server, and a response whose body could not be read. If you are porting a `catch` block, the replacement is an `IsSuccess` check, not a narrower `catch`. **Legacy:** ```csharp @@ -215,7 +215,9 @@ if (!result.IsSuccess && result.Error?.ErrorCode == ManagementErrorCodes.Publish > The codes are **not unique** — the API reuses some across unrelated conditions — so inspect `Message` as well when the distinction matters. > [!IMPORTANT] -> Exceptions are now reserved for **programmer errors** (e.g. a `null` argument), **invalid configuration**, and **network/serialization failures** — not for API errors. A `404` or an API validation rejection comes back as `IsSuccess == false`, never as a thrown exception. +> A failed call is a result, not an exception — a `404`, an API validation rejection, an unreachable host and an unreadable response body all come back as `IsSuccess == false`, with `Error.Exception` carrying the exception where there was one. +> +> What still throws: **cancellation** (`OperationCanceledException`, so `Task.IsCanceled` and cancellation handlers keep working — an expired timeout is not cancellation and comes back as a result), **programmer errors** such as a `null` argument, **invalid configuration**, and `EnsureSuccess()` when you opt into throwing. The strongly-typed language-variant overloads add one: projecting a response onto a generated record that no longer matches the content type throws. --- diff --git a/src/model-generator/CHANGELOG.md b/src/model-generator/CHANGELOG.md index 050b94e26..3ebc668d9 100644 --- a/src/model-generator/CHANGELOG.md +++ b/src/model-generator/CHANGELOG.md @@ -9,6 +9,38 @@ Entries before the move to this monorepo were imported from the GitHub Releases ## Unreleased +### Fixed + +- **The tool no longer ships the Visual Basic compiler.** `Microsoft.CodeAnalysis` is the meta-package; only the C# syntax and workspace formatting APIs are used, so it now references `Microsoft.CodeAnalysis.CSharp.Workspaces` directly. + +- **A blank comment argument reports `ArgumentException` rather than `ArgumentNullException`.** The value is present, just empty — the two are different mistakes, and the tests had frozen the wrong one. + +- **Generated members are ordered ordinally rather than by the current culture**, so the same content model produces the same file on every machine. + +- **`appSettings.json` names the option the tool actually reads.** It still listed `BaseClass`, which was renamed to `BaseRecord`. + +- **The startup banner no longer reports success before anything is generated.** A failed run's first line was "Models were generated for …"; it now says what it is about to do. + +- **`IClassCodeGeneratorFactory` covers both emitters.** It offered only the Delivery generator while the Management path constructed its own directly — a seam that looked like the way in and was not. It now has a method per emitter, and the Management path goes through it. + +- **The config-file documentation matches where the tool actually looks.** The README described `appSettings.json` as living beside the executable; the tool reads it from the working directory, and as a `dotnet tool` it has no executable directory to speak of. The file is also not installed with the tool, so the README now points at it as a template to copy. + +- **Management mode no longer skips elements over identifiers it never emits.** Every content type reserved the names the Delivery emitter uses for its codename constants — `{Property}Codename` for each element, plus the type's own `ContentTypeCodename` — regardless of mode. The Management emitter writes none of those, so the reservation only rejected valid input there: a type carrying both `title` and `title_codename` had the second skipped with a collision warning, and an element codenamed `content_type_codename` was renamed for no reason. Constant registration is now the Delivery emitter's, so Management mode has the whole identifier space its own output uses. + +- **`--baseRecord` is rejected at startup when it is not a valid C# record name.** `-b "My-Base"` wrote `public partial record My-Base` and an extender deriving every generated model from it, so the whole output failed to compile over one argument. The name is checked before any API call and reported like any other configuration problem. + +- **Forgetting `--management` now fails instead of generating Delivery models.** Validation accepted the union of both modes' parameters while binding only ever applied the active mode's, so `-k` (or `--apiKey`) without `-m` was accepted, dropped, and the run continued as a full Delivery generation - writing Delivery models over whatever was in the output directory and exiting `0`. The reverse dropped the Delivery-only `-p` / `--projectid` in Management mode and then failed with a message about an empty `EnvironmentId`, which read as a configuration problem rather than a wrong flag. Each argument is now checked against the mode that is actually running, and one that belongs to the other mode names the mode switch: + + ``` + -k configures the Management API. Add --management (or -m) to generate from it. + ``` + + The same check now covers the section-qualified form (`--ManagementOptions:ApiKey` without `--management`, and `--DeliveryOptions:*` with it), which bound straight into configuration without needing a switch mapping and so slipped past the mode entirely. Only command-line arguments are checked - an `appSettings.json` carrying both sections is unaffected, and the section belonging to the mode you run is the one that is read. + +- **`--nullability` is refused in Management mode instead of accepted and ignored.** It selects how generated *Delivery* models express nullability. Management models are uniformly nullable by contract - a `null` property is omitted from the upsert payload, which is how you leave an element untouched - so there was never anything for the flag to select there. The parameter table already documented it as Delivery-only; now the tool enforces it. + +- **`--management` together with `--baseRecord` no longer emits code that cannot compile.** The generated base record and its extender both carried a hardcoded `using Kontent.Ai.Delivery.Abstractions;`. A project generated for the Management SDK has no reason to reference the Delivery SDK, so that line was a `CS0246` in a file the consumer never wrote. Neither it nor the `using System;` beside it was referenced by the emitted code in either mode; both are gone. + ## 11.0.0-rc.1 (2026-08-07) _(prerelease)_ Targets .NET 10. Both packages move from `net8.0` to `net10.0`, which is why this is a major release rather than a continuation of the `10.3.0` line. Generated output is unchanged. @@ -58,7 +90,7 @@ Targets .NET 10. Both packages move from `net8.0` to `net10.0`, which is why thi A rejected element no longer half-registers either — the constant used to be recorded before the property could be refused, so skipping one element still corrupted the output. - **An element that fails for an unanticipated reason is now reported instead of vanishing.** Per-element failures were classified by a `switch` with arms for the three expected exception types and no default, so anything else was caught, matched nothing, and left the element out of the generated model with nothing written to the console. - **The tool no longer claims to have created a base record it did not write.** `--baserecord` deliberately does not overwrite an existing file, so hand-written additions survive a rerun — but the run printed "`` class was successfully created" either way. It now says the file was kept, and `IOutputProvider.Output` returns whether it wrote (see Breaking changes). -- **The "no content type available" message names the environment in management mode.** It read the Delivery options only, so a `--managementapi` run against an empty environment reported the id as blank. +- **The "no content type available" message names the environment in management mode.** It read the Delivery options only, so a `--management` run against an empty environment reported the id as blank. - **A failure with more than one inner exception no longer exits silently.** `Main` had a special case for `AggregateException` that printed the message only when there was exactly one inner exception and otherwise returned exit code 1 with no output at all. `await` unwraps these anyway, so the case was vestigial; it is removed and the general handler reports every failure. - **Two content types that map to the same file no longer silently overwrite each other.** Type codenames sanitize to a class name the same way element codenames do, so `my_type` and `my__type` both wrote `MyType.cs` — the second overwrote the first, and the run reported both as created. The duplicate is now skipped with a warning, and the "N content type models were successfully created" count reflects what was actually written. diff --git a/src/model-generator/Kontent.Ai.ModelGenerator.Core.Tests/ApiApproval/PublicApiApprovalTests.ModelGeneratorCorePublicApi_ShouldNotChangeUnexpectedly.verified.txt b/src/model-generator/Kontent.Ai.ModelGenerator.Core.Tests/ApiApproval/PublicApiApprovalTests.ModelGeneratorCorePublicApi_ShouldNotChangeUnexpectedly.verified.txt new file mode 100644 index 000000000..a87bc90b0 --- /dev/null +++ b/src/model-generator/Kontent.Ai.ModelGenerator.Core.Tests/ApiApproval/PublicApiApprovalTests.ModelGeneratorCorePublicApi_ShouldNotChangeUnexpectedly.verified.txt @@ -0,0 +1,323 @@ +// Kontent.Ai.ModelGenerator.Core +public abstract class CodeGeneratorBase + Task RunAsync() + +// Kontent.Ai.ModelGenerator.Core +public class DeliveryCodeGenerator : CodeGeneratorBase + .ctor(IOptions options, IOutputProvider outputProvider, IDeliveryClient deliveryClient, IClassCodeGeneratorFactory classCodeGeneratorFactory, IClassDefinitionFactory classDefinitionFactory, IUserMessageLogger logger) + +// Kontent.Ai.ModelGenerator.Core +public class ManagementCodeGenerator : CodeGeneratorBase + .ctor(IOptions options, IOutputProvider outputProvider, IManagementClient managementClient, IClassCodeGeneratorFactory classCodeGeneratorFactory, IClassDefinitionFactory classDefinitionFactory, IManagementElementService elementService, IUserMessageLogger logger) + +// Kontent.Ai.ModelGenerator.Core.Common +public sealed class AssetElementInput : ManagementElementInput, IEquatable + .ctor(String Codename, String Id) + AssetElementInput $() + Boolean Equals(AssetElementInput? other) + Boolean Equals(ManagementElementInput? other) + Boolean Equals(Object? obj) + Int32 GetHashCode() + String ToString() + Void Deconstruct(out String Codename, out String Id) + +// Kontent.Ai.ModelGenerator.Core.Common +public sealed class AttributeArg + .ctor(Object value, String? name) + Object Value { get; } + String? Name { get; } + static AttributeArg Named(String name, Object value) + static AttributeArg Positional(Object value) + +// Kontent.Ai.ModelGenerator.Core.Common +public sealed class AttributeSpec + .ctor(String name, IReadOnlyList? arguments) + IReadOnlyList Arguments { get; } + String Name { get; } + +// Kontent.Ai.ModelGenerator.Core.Common +public class ClassCodeGeneratorFactory : IClassCodeGeneratorFactory + .ctor() + ClassCodeGenerator CreateClassCodeGenerator(CodeGeneratorOptions options, ClassDefinition classDefinition, String classFilename) + ClassCodeGenerator CreateManagementClassCodeGenerator(CodeGeneratorOptions options, ClassDefinition classDefinition, String classFilename) + +// Kontent.Ai.ModelGenerator.Core.Common +public class ClassDefinition + const String ContentTypeCodenameIdentifier = ContentTypeCodename + .ctor(String codeName, Boolean emitsCodenameConstants) + IReadOnlySet RenamedCodenameConstants { get; } + List Enums { get; } + List Properties { get; } + List PropertyCodenameConstants { get; } + String ClassName { get; } + String Codename { get; } + String? Id { get; set; } + Void AddEnum(EnumDefinition definition) + Void AddProperty(Property property) + +// Kontent.Ai.ModelGenerator.Core.Common +public class ClassDefinitionFactory : IClassDefinitionFactory + .ctor() + ClassDefinition CreateClassDefinition(String codename) + ClassDefinition CreateClassDefinitionWithoutCodenameConstants(String codename) + +// Kontent.Ai.ModelGenerator.Core.Common +public sealed class CustomElementInput : ManagementElementInput, IEquatable + .ctor(String Codename, String Id) + Boolean Equals(CustomElementInput? other) + Boolean Equals(ManagementElementInput? other) + Boolean Equals(Object? obj) + CustomElementInput $() + Int32 GetHashCode() + String ToString() + Void Deconstruct(out String Codename, out String Id) + +// Kontent.Ai.ModelGenerator.Core.Common +public sealed class DateTimeElementInput : ManagementElementInput, IEquatable + .ctor(String Codename, String Id) + Boolean Equals(DateTimeElementInput? other) + Boolean Equals(ManagementElementInput? other) + Boolean Equals(Object? obj) + DateTimeElementInput $() + Int32 GetHashCode() + String ToString() + Void Deconstruct(out String Codename, out String Id) + +// Kontent.Ai.ModelGenerator.Core.Common +public sealed class EnumDefinition + .ctor(String name, IReadOnlyList members) + IReadOnlyList Members { get; } + String Name { get; } + +// Kontent.Ai.ModelGenerator.Core.Common +public sealed class EnumMember + .ctor(String identifier, IReadOnlyList attributes) + IReadOnlyList Attributes { get; } + String Identifier { get; } + +// Kontent.Ai.ModelGenerator.Core.Common +public class InvalidIdentifierException : Exception + .ctor(String message) + +// Kontent.Ai.ModelGenerator.Core.Common +public sealed class LinkedItemsElementInput : ManagementElementInput, IEquatable + .ctor(String Codename, String Id) + Boolean Equals(LinkedItemsElementInput? other) + Boolean Equals(ManagementElementInput? other) + Boolean Equals(Object? obj) + Int32 GetHashCode() + LinkedItemsElementInput $() + String ToString() + Void Deconstruct(out String Codename, out String Id) + +// Kontent.Ai.ModelGenerator.Core.Common +public abstract class ManagementElementInput : IEquatable + String Codename { get; init; } + String Id { get; init; } + Boolean Equals(ManagementElementInput? other) + Boolean Equals(Object? obj) + Int32 GetHashCode() + ManagementElementInput $() + String ToString() + Void Deconstruct(out String Codename, out String Id) + +// Kontent.Ai.ModelGenerator.Core.Common +public sealed class ManagementElementOutput + .ctor(ManagementProperty property, IReadOnlyList? enums) + IReadOnlyList Enums { get; } + ManagementProperty Property { get; } + +// Kontent.Ai.ModelGenerator.Core.Common +public sealed class ManagementProperty : Property + .ctor(String codename, String typeName, String id, IReadOnlyList attributes) + IReadOnlyList Attributes { get; } + +// Kontent.Ai.ModelGenerator.Core.Common +public sealed class MultipleChoiceElementInput : ManagementElementInput, IEquatable + .ctor(String Codename, String Id, String EnumTypeName, IReadOnlyList Options) + IReadOnlyList Options { get; init; } + String EnumTypeName { get; init; } + Boolean Equals(ManagementElementInput? other) + Boolean Equals(MultipleChoiceElementInput? other) + Boolean Equals(Object? obj) + Int32 GetHashCode() + MultipleChoiceElementInput $() + String ToString() + Void Deconstruct(out String Codename, out String Id, out String EnumTypeName, out IReadOnlyList Options) + +// Kontent.Ai.ModelGenerator.Core.Common +public sealed class MultipleChoiceOptionInput : IEquatable + .ctor(String Codename, String Id) + String Codename { get; init; } + String Id { get; init; } + Boolean Equals(MultipleChoiceOptionInput? other) + Boolean Equals(Object? obj) + Int32 GetHashCode() + MultipleChoiceOptionInput $() + String ToString() + Void Deconstruct(out String Codename, out String Id) + +// Kontent.Ai.ModelGenerator.Core.Common +public sealed class NumberElementInput : ManagementElementInput, IEquatable + .ctor(String Codename, String Id) + Boolean Equals(ManagementElementInput? other) + Boolean Equals(NumberElementInput? other) + Boolean Equals(Object? obj) + Int32 GetHashCode() + NumberElementInput $() + String ToString() + Void Deconstruct(out String Codename, out String Id) + +// Kontent.Ai.ModelGenerator.Core.Common +public class Property + .ctor(String codename, String typeName, String? id, String? initializer) + String Codename { get; } + String Identifier { get; } + String TypeName { get; } + String? Id { get; } + String? IdentifierOverride { get; init; } + String? Initializer { get; } + static Property FromContentTypeElement(String codename, String elementType) + static Property FromContentTypeElement(String codename, String elementType, NullabilityMode nullabilityMode) + +// Kontent.Ai.ModelGenerator.Core.Common +public sealed class RichTextElementInput : ManagementElementInput, IEquatable + .ctor(String Codename, String Id) + Boolean Equals(ManagementElementInput? other) + Boolean Equals(Object? obj) + Boolean Equals(RichTextElementInput? other) + Int32 GetHashCode() + RichTextElementInput $() + String ToString() + Void Deconstruct(out String Codename, out String Id) + +// Kontent.Ai.ModelGenerator.Core.Common +public static class SnippetExpander + static IEnumerable Expand(IEnumerable elements, Func resolveSnippet, Action warn) + +// Kontent.Ai.ModelGenerator.Core.Common +public sealed class SubpagesElementInput : ManagementElementInput, IEquatable + .ctor(String Codename, String Id) + Boolean Equals(ManagementElementInput? other) + Boolean Equals(Object? obj) + Boolean Equals(SubpagesElementInput? other) + Int32 GetHashCode() + String ToString() + SubpagesElementInput $() + Void Deconstruct(out String Codename, out String Id) + +// Kontent.Ai.ModelGenerator.Core.Common +public sealed class TaxonomyElementInput : ManagementElementInput, IEquatable + .ctor(String Codename, String Id) + Boolean Equals(ManagementElementInput? other) + Boolean Equals(Object? obj) + Boolean Equals(TaxonomyElementInput? other) + Int32 GetHashCode() + String ToString() + TaxonomyElementInput $() + Void Deconstruct(out String Codename, out String Id) + +// Kontent.Ai.ModelGenerator.Core.Common +public sealed class TextElementInput : ManagementElementInput, IEquatable + .ctor(String Codename, String Id) + Boolean Equals(ManagementElementInput? other) + Boolean Equals(Object? obj) + Boolean Equals(TextElementInput? other) + Int32 GetHashCode() + String ToString() + TextElementInput $() + Void Deconstruct(out String Codename, out String Id) + +// Kontent.Ai.ModelGenerator.Core.Common +public sealed class UrlSlugElementInput : ManagementElementInput, IEquatable + .ctor(String Codename, String Id) + Boolean Equals(ManagementElementInput? other) + Boolean Equals(Object? obj) + Boolean Equals(UrlSlugElementInput? other) + Int32 GetHashCode() + String ToString() + UrlSlugElementInput $() + Void Deconstruct(out String Codename, out String Id) + +// Kontent.Ai.ModelGenerator.Core.Configuration +public class CodeGeneratorOptions + .ctor() + DeliveryOptions? DeliveryOptions { get; set; } + ManagementOptions? ManagementOptions { get; set; } + NullabilityMode Nullability { get; set; } + String? BaseClass { get; set; } + String? BaseRecord { get; set; } + String? Namespace { get; set; } + String? OutputDir { get; set; } + +// Kontent.Ai.ModelGenerator.Core.Configuration +public static class CodeGeneratorOptionsExtensions + static String? GetEnvironmentId(CodeGeneratorOptions options) + +// Kontent.Ai.ModelGenerator.Core.Configuration +public enum NullabilityMode + Semantic = 1 + Strict = 0 + +// Kontent.Ai.ModelGenerator.Core.Contract +public interface IClassCodeGeneratorFactory + ClassCodeGenerator CreateClassCodeGenerator(CodeGeneratorOptions options, ClassDefinition classDefinition, String classFilename) + ClassCodeGenerator CreateManagementClassCodeGenerator(CodeGeneratorOptions options, ClassDefinition classDefinition, String classFilename) + +// Kontent.Ai.ModelGenerator.Core.Contract +public interface IClassDefinitionFactory + ClassDefinition CreateClassDefinition(String codename) + ClassDefinition CreateClassDefinitionWithoutCodenameConstants(String codename) + +// Kontent.Ai.ModelGenerator.Core.Contract +public interface IManagementElementService + ManagementElementOutput Build(ManagementElementInput input) + +// Kontent.Ai.ModelGenerator.Core.Contract +public interface IOutputProvider + Boolean Output(String content, String fileName, Boolean overwriteExisting) + +// Kontent.Ai.ModelGenerator.Core.Contract +public interface IUserMessageLogger + Task LogErrorAsync(String message) + Void LogInfo(String message) + Void LogWarning(String message) + +// Kontent.Ai.ModelGenerator.Core.Generators +public abstract class GeneralGenerator + readonly String Namespace + +// Kontent.Ai.ModelGenerator.Core.Generators.Class +public class BaseClassCodeGenerator : GeneralGenerator + .ctor(CodeGeneratorOptions options) + String ExtenderClassName { get; } + String GenerateBaseClassCode() + String GenerateExtenderCode() + Void AddClassNameToExtend(String className) + +// Kontent.Ai.ModelGenerator.Core.Generators.Class +public abstract class ClassCodeGenerator : GeneralGenerator + const String DefaultNamespace = KontentAiModels + ClassDefinition ClassDefinition { get; } + String ClassFilename { get; } + String GenerateCode() + +// Kontent.Ai.ModelGenerator.Core.Generators.Class +public sealed class DeliveryClassCodeGenerator : ClassCodeGenerator + .ctor(ClassDefinition classDefinition, String classFilename, String? namespace) + +// Kontent.Ai.ModelGenerator.Core.Generators.Class +public sealed class ManagementClassCodeGenerator : ClassCodeGenerator + .ctor(ClassDefinition classDefinition, String classFilename, String? namespace) + +// Kontent.Ai.ModelGenerator.Core.Helpers +public static class TextHelpers + static String GenerateCommentString(String customComment) + static String GetValidPascalCaseIdentifierName(String name) + static String NormalizeLineEndings(String text) + +// Kontent.Ai.ModelGenerator.Core.Services +public sealed class ManagementElementService : IManagementElementService + .ctor() + ManagementElementOutput Build(ManagementElementInput input) + diff --git a/src/model-generator/Kontent.Ai.ModelGenerator.Core.Tests/ApiApproval/PublicApiApprovalTests.cs b/src/model-generator/Kontent.Ai.ModelGenerator.Core.Tests/ApiApproval/PublicApiApprovalTests.cs new file mode 100644 index 000000000..4c61aac80 --- /dev/null +++ b/src/model-generator/Kontent.Ai.ModelGenerator.Core.Tests/ApiApproval/PublicApiApprovalTests.cs @@ -0,0 +1,11 @@ +using Kontent.Ai.ModelGenerator.Core.Common; +using Kontent.Ai.Testing; + +namespace Kontent.Ai.ModelGenerator.Core.Tests.ApiApproval; + +public class PublicApiApprovalTests +{ + [Fact] + public Task ModelGeneratorCorePublicApi_ShouldNotChangeUnexpectedly() + => Verify(PublicApiApproval.Surface(typeof(ClassDefinition).Assembly)); +} diff --git a/src/model-generator/Kontent.Ai.ModelGenerator.Core.Tests/Common/ClassDefinitionTests.cs b/src/model-generator/Kontent.Ai.ModelGenerator.Core.Tests/Common/ClassDefinitionTests.cs index a652d5ad3..34e524b61 100644 --- a/src/model-generator/Kontent.Ai.ModelGenerator.Core.Tests/Common/ClassDefinitionTests.cs +++ b/src/model-generator/Kontent.Ai.ModelGenerator.Core.Tests/Common/ClassDefinitionTests.cs @@ -117,4 +117,40 @@ public void AddProperty_NoCollision_NotTrackedAsRenamed() classDefinition.RenamedCodenameConstants.Should().BeEmpty(); } + + // The Delivery emitter writes a {Property}Codename constant per element plus the type's own + // ContentTypeCodename; the Management emitter writes neither. Reserving those names in Management mode + // rejected element pairs that would have generated fine. + [Fact] + public void AddProperty_WithoutCodenameConstants_AcceptsAnElementNamedAfterAnothersConstant() + { + var classDefinition = new ClassDefinition("article", emitsCodenameConstants: false); + + classDefinition.AddProperty(new Property("title", "string")); + var act = () => classDefinition.AddProperty(new Property("title_codename", "string")); + + act.Should().NotThrow(); + classDefinition.Properties.Should().HaveCount(2); + } + + [Fact] + public void AddProperty_WithoutCodenameConstants_LeavesContentTypeCodenameAvailable() + { + var classDefinition = new ClassDefinition("article", emitsCodenameConstants: false); + + classDefinition.AddProperty(new Property("content_type_codename", "string")); + + classDefinition.Properties.Single().Identifier.Should().Be(ClassDefinition.ContentTypeCodenameIdentifier); + } + + [Fact] + public void AddProperty_WithCodenameConstants_StillGuardsTheCollision() + { + var classDefinition = new ClassDefinition("article"); + + classDefinition.AddProperty(new Property("title", "string")); + var act = () => classDefinition.AddProperty(new Property("title_codename", "string")); + + act.Should().Throw(); + } } diff --git a/src/model-generator/Kontent.Ai.ModelGenerator.Core.Tests/Generators/Class/BaseClassCodeGeneratorTests.cs b/src/model-generator/Kontent.Ai.ModelGenerator.Core.Tests/Generators/Class/BaseClassCodeGeneratorTests.cs new file mode 100644 index 000000000..4a88afbf4 --- /dev/null +++ b/src/model-generator/Kontent.Ai.ModelGenerator.Core.Tests/Generators/Class/BaseClassCodeGeneratorTests.cs @@ -0,0 +1,36 @@ +using Kontent.Ai.ModelGenerator.Core.Configuration; +using Kontent.Ai.ModelGenerator.Core.Generators.Class; + +namespace Kontent.Ai.ModelGenerator.Core.Tests.Generators.Class; + +/// +/// The base record and its extender are emitted the same way in both modes, so anything they name has to +/// resolve in both. A project generated with --management has no reason to reference the Delivery +/// SDK, and a using for it there is a CS0246 in code the consumer did not write. +/// +public class BaseClassCodeGeneratorTests +{ + private static BaseClassCodeGenerator Generator() => + new(new CodeGeneratorOptions { Namespace = "Test.Models", BaseRecord = "ArticleBase" }); + + [Fact] + public void GenerateBaseClassCode_ReferencesNoSdk() + { + var code = Generator().GenerateBaseClassCode(); + + Assert.DoesNotContain("using ", code); + Assert.Contains("public partial record ArticleBase", code); + } + + [Fact] + public void GenerateExtenderCode_ReferencesNoSdk() + { + var generator = Generator(); + generator.AddClassNameToExtend("Article"); + + var code = generator.GenerateExtenderCode(); + + Assert.DoesNotContain("using ", code); + Assert.Contains("public partial record Article : ArticleBase", code); + } +} diff --git a/src/model-generator/Kontent.Ai.ModelGenerator.Core.Tests/Helpers/ClassDeclarationHelperTests.cs b/src/model-generator/Kontent.Ai.ModelGenerator.Core.Tests/Helpers/ClassDeclarationHelperTests.cs index 23c29c1f2..c43dfa86e 100644 --- a/src/model-generator/Kontent.Ai.ModelGenerator.Core.Tests/Helpers/ClassDeclarationHelperTests.cs +++ b/src/model-generator/Kontent.Ai.ModelGenerator.Core.Tests/Helpers/ClassDeclarationHelperTests.cs @@ -5,16 +5,24 @@ namespace Kontent.Ai.ModelGenerator.Core.Tests.Helpers; public class ClassDeclarationHelperTests { + // A missing argument and a blank one are different mistakes: null is ArgumentNullException, a value + // that is present but empty is ArgumentException. The guard used to report both as the former. + [Fact] + public void GenerateSyntaxTrivia_CustomCommentIsNullOrWhiteSpace_Null_ThrowsArgumentNullException() + { + var act = () => ClassDeclarationHelper.GenerateSyntaxTrivia(null!); + + act.Should().ThrowExactly(); + } + [Theory] - [InlineData(null)] [InlineData("")] - [InlineData(" ")] - public void GenerateSyntaxTrivia_CustomCommentIsNullOrWhiteSpace_Throws(string? customComment) + [InlineData(" ")] + public void GenerateSyntaxTrivia_CustomCommentIsNullOrWhiteSpace_Blank_ThrowsArgumentException(string customComment) { - var call = () => ClassDeclarationHelper.GenerateSyntaxTrivia(customComment!); + var act = () => ClassDeclarationHelper.GenerateSyntaxTrivia(customComment!); - call.Should().Throw() - .And.ParamName.Should().Be(nameof(customComment)); + act.Should().ThrowExactly(); } [Theory] diff --git a/src/model-generator/Kontent.Ai.ModelGenerator.Core.Tests/Helpers/TextHelpersTests.cs b/src/model-generator/Kontent.Ai.ModelGenerator.Core.Tests/Helpers/TextHelpersTests.cs index ab735e9e6..e776a4d1d 100644 --- a/src/model-generator/Kontent.Ai.ModelGenerator.Core.Tests/Helpers/TextHelpersTests.cs +++ b/src/model-generator/Kontent.Ai.ModelGenerator.Core.Tests/Helpers/TextHelpersTests.cs @@ -40,15 +40,24 @@ public void GetValidPascalCaseIdentifierName_Returns(string name, string expecte result.Should().Be(expected); } + // A missing argument and a blank one are different mistakes: null is ArgumentNullException, a value + // that is present but empty is ArgumentException. The guard used to report both as the former. + [Fact] + public void GenerateCommentString_CustomCommentIsNullOrEmptyOrWhiteSpace_Null_ThrowsArgumentNullException() + { + var act = () => TextHelpers.GenerateCommentString(null!); + + act.Should().ThrowExactly(); + } + [Theory] - [InlineData(null)] [InlineData("")] [InlineData(" ")] - public void GenerateCommentString_CustomCommentIsNullOrEmptyOrWhiteSpace_Throws(string? customComment) + public void GenerateCommentString_CustomCommentIsNullOrEmptyOrWhiteSpace_Blank_ThrowsArgumentException(string customComment) { - var generateCommentStringCall = () => TextHelpers.GenerateCommentString(customComment!); + var act = () => TextHelpers.GenerateCommentString(customComment!); - generateCommentStringCall.Should().ThrowExactly(); + act.Should().ThrowExactly(); } [Fact] diff --git a/src/model-generator/Kontent.Ai.ModelGenerator.Core.Tests/Kontent.Ai.ModelGenerator.Core.Tests.csproj b/src/model-generator/Kontent.Ai.ModelGenerator.Core.Tests/Kontent.Ai.ModelGenerator.Core.Tests.csproj index 88930d72b..d91d8bcbe 100644 --- a/src/model-generator/Kontent.Ai.ModelGenerator.Core.Tests/Kontent.Ai.ModelGenerator.Core.Tests.csproj +++ b/src/model-generator/Kontent.Ai.ModelGenerator.Core.Tests/Kontent.Ai.ModelGenerator.Core.Tests.csproj @@ -32,10 +32,15 @@ + + + + + diff --git a/src/model-generator/Kontent.Ai.ModelGenerator.Core/Common/ClassCodeGeneratorFactory.cs b/src/model-generator/Kontent.Ai.ModelGenerator.Core/Common/ClassCodeGeneratorFactory.cs index 89c03e48a..0e4e061a5 100644 --- a/src/model-generator/Kontent.Ai.ModelGenerator.Core/Common/ClassCodeGeneratorFactory.cs +++ b/src/model-generator/Kontent.Ai.ModelGenerator.Core/Common/ClassCodeGeneratorFactory.cs @@ -17,4 +17,17 @@ public ClassCodeGenerator CreateClassCodeGenerator( return new DeliveryClassCodeGenerator(classDefinition, classFilename, options.Namespace); } + + /// + public ClassCodeGenerator CreateManagementClassCodeGenerator( + CodeGeneratorOptions options, + ClassDefinition classDefinition, + string classFilename) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(classDefinition); + ArgumentNullException.ThrowIfNull(classFilename); + + return new ManagementClassCodeGenerator(classDefinition, classFilename, options.Namespace); + } } diff --git a/src/model-generator/Kontent.Ai.ModelGenerator.Core/Common/ClassDefinition.cs b/src/model-generator/Kontent.Ai.ModelGenerator.Core/Common/ClassDefinition.cs index a72171dce..41566477d 100644 --- a/src/model-generator/Kontent.Ai.ModelGenerator.Core/Common/ClassDefinition.cs +++ b/src/model-generator/Kontent.Ai.ModelGenerator.Core/Common/ClassDefinition.cs @@ -2,7 +2,14 @@ namespace Kontent.Ai.ModelGenerator.Core.Common; -public class ClassDefinition(string codeName) +/// The content type's codename. +/// +/// Whether the generated record will carry the {Property}Codename constants and the type's own +/// . Only the Delivery emitter writes those, so only it can +/// collide over them - reserving the names in Management mode rejected element pairs that would have +/// generated perfectly well, and renamed a content_type_codename element for no reason. +/// +public class ClassDefinition(string codeName, bool emitsCodenameConstants = true) { public const string ContentTypeCodenameIdentifier = "ContentTypeCodename"; @@ -18,10 +25,9 @@ public class ClassDefinition(string codeName) /// and my__element, or title against title_codename - through into generated /// code that does not compile. /// - private readonly Dictionary _identifierOwners = new(StringComparer.Ordinal) - { - [ContentTypeCodenameIdentifier] = codeName, - }; + private readonly Dictionary _identifierOwners = emitsCodenameConstants + ? new(StringComparer.Ordinal) { [ContentTypeCodenameIdentifier] = codeName } + : new(StringComparer.Ordinal); private readonly HashSet _renamedCodenameConstants = []; @@ -64,6 +70,15 @@ public void AddProperty(Property property) { ArgumentNullException.ThrowIfNull(property); + if (!emitsCodenameConstants) + { + EnsureAvailable(property.Identifier, property.Codename); + _identifierOwners[property.Identifier] = property.Codename; + Properties.Add(property); + + return; + } + if (property.Identifier == ContentTypeCodenameIdentifier) { property = new Property(property.Codename, property.TypeName, property.Id, property.Initializer) diff --git a/src/model-generator/Kontent.Ai.ModelGenerator.Core/Common/ClassDefinitionFactory.cs b/src/model-generator/Kontent.Ai.ModelGenerator.Core/Common/ClassDefinitionFactory.cs index 5cca53701..8531d48f0 100644 --- a/src/model-generator/Kontent.Ai.ModelGenerator.Core/Common/ClassDefinitionFactory.cs +++ b/src/model-generator/Kontent.Ai.ModelGenerator.Core/Common/ClassDefinitionFactory.cs @@ -10,4 +10,12 @@ public ClassDefinition CreateClassDefinition(string codename) return new ClassDefinition(codename); } + + /// + public ClassDefinition CreateClassDefinitionWithoutCodenameConstants(string codename) + { + ArgumentException.ThrowIfNullOrWhiteSpace(codename); + + return new ClassDefinition(codename, emitsCodenameConstants: false); + } } diff --git a/src/model-generator/Kontent.Ai.ModelGenerator.Core/Contract/IClassCodeGeneratorFactory.cs b/src/model-generator/Kontent.Ai.ModelGenerator.Core/Contract/IClassCodeGeneratorFactory.cs index 5100b66a1..ffe21427e 100644 --- a/src/model-generator/Kontent.Ai.ModelGenerator.Core/Contract/IClassCodeGeneratorFactory.cs +++ b/src/model-generator/Kontent.Ai.ModelGenerator.Core/Contract/IClassCodeGeneratorFactory.cs @@ -6,8 +6,24 @@ namespace Kontent.Ai.ModelGenerator.Core.Contract; public interface IClassCodeGeneratorFactory { + /// + /// Creates the generator that emits Delivery models. + /// ClassCodeGenerator CreateClassCodeGenerator( CodeGeneratorOptions options, ClassDefinition classDefinition, string classFilename); + + /// + /// Creates the generator that emits Management models. + /// + /// + /// Its own method rather than a mode flag on the one above: the two emitters differ in what they write, + /// not in a setting, and this interface previously offered only the Delivery one while the Management + /// path constructed its generator directly - a seam that looked like the way in and was not. + /// + ClassCodeGenerator CreateManagementClassCodeGenerator( + CodeGeneratorOptions options, + ClassDefinition classDefinition, + string classFilename); } diff --git a/src/model-generator/Kontent.Ai.ModelGenerator.Core/Contract/IClassDefinitionFactory.cs b/src/model-generator/Kontent.Ai.ModelGenerator.Core/Contract/IClassDefinitionFactory.cs index e5c4f8443..ea4abb568 100644 --- a/src/model-generator/Kontent.Ai.ModelGenerator.Core/Contract/IClassDefinitionFactory.cs +++ b/src/model-generator/Kontent.Ai.ModelGenerator.Core/Contract/IClassDefinitionFactory.cs @@ -5,4 +5,10 @@ namespace Kontent.Ai.ModelGenerator.Core.Contract; public interface IClassDefinitionFactory { ClassDefinition CreateClassDefinition(string codename); + + /// + /// Creates a definition for an emitter that writes no codename constants, so the identifiers those + /// constants would occupy stay available to elements. + /// + ClassDefinition CreateClassDefinitionWithoutCodenameConstants(string codename); } diff --git a/src/model-generator/Kontent.Ai.ModelGenerator.Core/Generators/Class/BaseClassCodeGenerator.cs b/src/model-generator/Kontent.Ai.ModelGenerator.Core/Generators/Class/BaseClassCodeGenerator.cs index 98ecf47b0..1872fc7dc 100644 --- a/src/model-generator/Kontent.Ai.ModelGenerator.Core/Generators/Class/BaseClassCodeGenerator.cs +++ b/src/model-generator/Kontent.Ai.ModelGenerator.Core/Generators/Class/BaseClassCodeGenerator.cs @@ -42,10 +42,7 @@ public void AddClassNameToExtend(string className) public string GenerateBaseClassCode() { var tree = CSharpSyntaxTree.ParseText( - $@"using System; -using Kontent.Ai.Delivery.Abstractions; - -namespace {Namespace}; + $@"namespace {Namespace}; public partial record {_options.BaseRecord} {{ @@ -64,15 +61,12 @@ public partial record {_options.BaseRecord} /// public string GenerateExtenderCode() { - var extenders = _classesToExtend.OrderBy(c => c) + var extenders = _classesToExtend.OrderBy(c => c, StringComparer.Ordinal) .Select((c) => $"public partial record {c} : {_options.BaseRecord} {{ }}") .Aggregate((p, n) => p + Environment.NewLine + n); var tree = CSharpSyntaxTree.ParseText( - $@"using System; -using Kontent.Ai.Delivery.Abstractions; - -namespace {Namespace}; + $@"namespace {Namespace}; // These records extend the generated models to all inherit from the common basetype {_options.BaseRecord}. diff --git a/src/model-generator/Kontent.Ai.ModelGenerator.Core/Generators/Class/ClassCodeGenerator.cs b/src/model-generator/Kontent.Ai.ModelGenerator.Core/Generators/Class/ClassCodeGenerator.cs index a709f16f0..0677dfd72 100644 --- a/src/model-generator/Kontent.Ai.ModelGenerator.Core/Generators/Class/ClassCodeGenerator.cs +++ b/src/model-generator/Kontent.Ai.ModelGenerator.Core/Generators/Class/ClassCodeGenerator.cs @@ -42,7 +42,7 @@ public string GenerateCode() protected virtual AttributeListSyntax[] BuildPropertyAttributes(Property property) => []; protected virtual MemberDeclarationSyntax[] GetProperties() - => ClassDefinition.Properties.OrderBy(p => p.Identifier).Select(element => + => ClassDefinition.Properties.OrderBy(p => p.Identifier, StringComparer.Ordinal).Select(element => { var property = SyntaxFactory .PropertyDeclaration(SyntaxFactory.ParseTypeName(element.TypeName), element.Identifier) diff --git a/src/model-generator/Kontent.Ai.ModelGenerator.Core/Generators/Class/DeliveryClassCodeGenerator.cs b/src/model-generator/Kontent.Ai.ModelGenerator.Core/Generators/Class/DeliveryClassCodeGenerator.cs index 2851a5b4c..dffbc19c2 100644 --- a/src/model-generator/Kontent.Ai.ModelGenerator.Core/Generators/Class/DeliveryClassCodeGenerator.cs +++ b/src/model-generator/Kontent.Ai.ModelGenerator.Core/Generators/Class/DeliveryClassCodeGenerator.cs @@ -61,7 +61,7 @@ protected override AttributeListSyntax[] BuildPropertyAttributes(Property proper private MemberDeclarationSyntax[] GetPropertyCodenameConstants() => ClassDefinition.PropertyCodenameConstants - .OrderBy(p => p) + .OrderBy(p => p, StringComparer.Ordinal) .Select(codename => { var identifier = $"{TextHelpers.GetValidPascalCaseIdentifierName(codename)}Codename"; diff --git a/src/model-generator/Kontent.Ai.ModelGenerator.Core/Helpers/ClassDeclarationHelper.cs b/src/model-generator/Kontent.Ai.ModelGenerator.Core/Helpers/ClassDeclarationHelper.cs index d2ee330da..65cc33997 100644 --- a/src/model-generator/Kontent.Ai.ModelGenerator.Core/Helpers/ClassDeclarationHelper.cs +++ b/src/model-generator/Kontent.Ai.ModelGenerator.Core/Helpers/ClassDeclarationHelper.cs @@ -7,10 +7,7 @@ internal static class ClassDeclarationHelper { public static SyntaxTrivia GenerateSyntaxTrivia(string customComment) { - if (string.IsNullOrWhiteSpace(customComment)) - { - throw new ArgumentNullException(nameof(customComment)); - } + ArgumentException.ThrowIfNullOrWhiteSpace(customComment); if (!customComment.StartsWith("// ")) { diff --git a/src/model-generator/Kontent.Ai.ModelGenerator.Core/Helpers/TextHelpers.cs b/src/model-generator/Kontent.Ai.ModelGenerator.Core/Helpers/TextHelpers.cs index bc32ab260..6c7428d7e 100644 --- a/src/model-generator/Kontent.Ai.ModelGenerator.Core/Helpers/TextHelpers.cs +++ b/src/model-generator/Kontent.Ai.ModelGenerator.Core/Helpers/TextHelpers.cs @@ -35,10 +35,7 @@ public static string NormalizeLineEndings(this string text) => public static string GenerateCommentString(string customComment) { - if (string.IsNullOrWhiteSpace(customComment)) - { - throw new ArgumentNullException(nameof(customComment)); - } + ArgumentException.ThrowIfNullOrWhiteSpace(customComment); return @$"// diff --git a/src/model-generator/Kontent.Ai.ModelGenerator.Core/Kontent.Ai.ModelGenerator.Core.csproj b/src/model-generator/Kontent.Ai.ModelGenerator.Core/Kontent.Ai.ModelGenerator.Core.csproj index 73fc0d505..29ff56f5e 100644 --- a/src/model-generator/Kontent.Ai.ModelGenerator.Core/Kontent.Ai.ModelGenerator.Core.csproj +++ b/src/model-generator/Kontent.Ai.ModelGenerator.Core/Kontent.Ai.ModelGenerator.Core.csproj @@ -10,7 +10,7 @@ - + diff --git a/src/model-generator/Kontent.Ai.ModelGenerator.Core/ManagementCodeGenerator.cs b/src/model-generator/Kontent.Ai.ModelGenerator.Core/ManagementCodeGenerator.cs index 2975703bf..8e22f2fe3 100644 --- a/src/model-generator/Kontent.Ai.ModelGenerator.Core/ManagementCodeGenerator.cs +++ b/src/model-generator/Kontent.Ai.ModelGenerator.Core/ManagementCodeGenerator.cs @@ -65,7 +65,7 @@ internal ClassCodeGenerator BuildClassCodeGenerator( ContentTypeModel contentType, Func resolveSnippet) { - var classDefinition = ClassDefinitionFactory.CreateClassDefinition(contentType.Codename); + var classDefinition = ClassDefinitionFactory.CreateClassDefinitionWithoutCodenameConstants(contentType.Codename); if (contentType.Id != Guid.Empty) { classDefinition.Id = contentType.Id.ToString(); @@ -84,7 +84,7 @@ internal ClassCodeGenerator BuildClassCodeGenerator( } var classFilename = classDefinition.ClassName; - return new ManagementClassCodeGenerator(classDefinition, classFilename, Options.Namespace); + return ClassCodeGeneratorFactory.CreateManagementClassCodeGenerator(Options, classDefinition, classFilename); } private async Task> FetchAllSnippets() diff --git a/src/model-generator/Kontent.Ai.ModelGenerator.Tests/CommandLine/ArgHelpersTests.cs b/src/model-generator/Kontent.Ai.ModelGenerator.Tests/CommandLine/ArgHelpersTests.cs index dcd512b43..cec391990 100644 --- a/src/model-generator/Kontent.Ai.ModelGenerator.Tests/CommandLine/ArgHelpersTests.cs +++ b/src/model-generator/Kontent.Ai.ModelGenerator.Tests/CommandLine/ArgHelpersTests.cs @@ -152,10 +152,40 @@ public void FindInvalidArgs_ManagementSwitch_Accepted(string[] args) } [Fact] - public void FindInvalidArgs_ManagementOptionsLongForm_Accepted() + public void FindInvalidArgs_ManagementOptionsLongForm_AcceptedInManagementMode() { - ArgHelpers.FindInvalidArgs(["--ManagementOptions:EnvironmentId=abc"]).Should().BeEmpty(); - ArgHelpers.FindInvalidArgs(["--ManagementOptions:ApiKey=xyz"]).Should().BeEmpty(); + ArgHelpers.FindInvalidArgs(["-m", "--ManagementOptions:EnvironmentId=abc"]).Should().BeEmpty(); + ArgHelpers.FindInvalidArgs(["-m", "--ManagementOptions:ApiKey=xyz"]).Should().BeEmpty(); + } + + // The section-qualified form binds without a switch mapping, so it survived the mode check that the + // short flags go through: `-i --ManagementOptions:ApiKey=xyz` generated Delivery models, exit 0. + [Theory] + [InlineData("--ManagementOptions:ApiKey=xyz")] + [InlineData("--ManagementOptions:EnvironmentId=abc")] + public void FindInvalidArgs_ManagementOptionsLongForm_RejectedWithoutTheModeSwitch(string argument) + { + var result = ArgHelpers.FindInvalidArgs(["-i", "abc-123", argument]); + + result.Should().ContainSingle().Which.Should().Contain("--management"); + } + + [Fact] + public void FindInvalidArgs_DeliveryOptionsLongForm_RejectedInManagementMode() + { + var result = ArgHelpers.FindInvalidArgs(["-m", "--DeliveryOptions:EnvironmentId=abc"]); + + result.Should().ContainSingle().Which.Should().Contain("Delivery API"); + } + + [Fact] + public void FindInvalidArgs_Nullability_RejectedInManagementMode() + { + // Management models are uniformly nullable by contract, so there is nothing for this to select - + // it used to be accepted and then quietly do nothing. + var result = ArgHelpers.FindInvalidArgs(["-m", "--nullability=semantic"]); + + result.Should().ContainSingle().Which.Should().Contain("Delivery models only"); } [Fact] @@ -175,4 +205,39 @@ public void ConfigurationBinding_ManagementMode_PopulatesManagementOptions() // -i in management mode should NOT also populate DeliveryOptions. options.DeliveryOptions?.EnvironmentId.Should().BeNullOrEmpty(); } + + [Theory] + [InlineData("-k")] + [InlineData("--apiKey")] + public void FindInvalidArgs_ManagementArgWithoutTheModeSwitch_PointsAtTheModeSwitch(string argument) + { + var result = ArgHelpers.FindInvalidArgs([argument, "secret"]); + + result.Should().ContainSingle().Which.Should().Contain("--management"); + } + + [Theory] + [InlineData("-p")] + [InlineData("--projectid")] + public void FindInvalidArgs_DeliveryArgInManagementMode_SaysItIsNotUsedThere(string argument) + { + var result = ArgHelpers.FindInvalidArgs(["-m", argument, "abc-123"]); + + result.Should().ContainSingle().Which.Should().Contain("Delivery API"); + } + + public static TheoryData ArgsBelongingToTheActiveMode => new() + { + new[] { "-m", "-k", "secret" }, + new[] { "-m", "-i", "abc-123" }, + new[] { "-i", "abc-123" }, + new[] { "-p", "abc-123" }, + }; + + [Theory] + [MemberData(nameof(ArgsBelongingToTheActiveMode))] + public void FindInvalidArgs_ArgumentBelongingToTheActiveMode_ReturnsNoProblems(string[] args) + { + ArgHelpers.FindInvalidArgs(args).Should().BeEmpty(); + } } diff --git a/src/model-generator/Kontent.Ai.ModelGenerator.Tests/CommandLine/ValidationExtensionsTests.cs b/src/model-generator/Kontent.Ai.ModelGenerator.Tests/CommandLine/ValidationExtensionsTests.cs index a3b66875b..f3bebc486 100644 --- a/src/model-generator/Kontent.Ai.ModelGenerator.Tests/CommandLine/ValidationExtensionsTests.cs +++ b/src/model-generator/Kontent.Ai.ModelGenerator.Tests/CommandLine/ValidationExtensionsTests.cs @@ -137,4 +137,38 @@ public void ValidateManagement_MultipleProblems_ReportsAll() call.Should().Throw() .Which.Message.Should().Contain("EnvironmentId").And.Contain("ApiKey"); } + + [Theory] + [InlineData("My-Base")] + [InlineData("1Base")] + [InlineData("My Base")] + public void Validate_InvalidBaseRecordName_Throws(string baseRecord) + { + // Written into the generated code verbatim, so anything that is not an identifier produces a file + // that cannot compile - and an extender deriving every model from it. + var options = new CodeGeneratorOptions + { + DeliveryOptions = new DeliveryOptions { EnvironmentId = Guid.NewGuid().ToString() }, + BaseRecord = baseRecord + }; + + var act = () => options.Validate(); + + act.Should().Throw().WithMessage("*not a valid C# record name*"); + } + + [Theory] + [InlineData("MyBase")] + [InlineData("_Base")] + [InlineData(null)] + public void Validate_ValidOrAbsentBaseRecordName_DoesNotThrow(string? baseRecord) + { + var options = new CodeGeneratorOptions + { + DeliveryOptions = new DeliveryOptions { EnvironmentId = Guid.NewGuid().ToString() }, + BaseRecord = baseRecord + }; + + options.Invoking(o => o.Validate()).Should().NotThrow(); + } } diff --git a/src/model-generator/Kontent.Ai.ModelGenerator.Tests/ProgramTests.cs b/src/model-generator/Kontent.Ai.ModelGenerator.Tests/ProgramTests.cs index b7633cb4a..6842dfc7b 100644 --- a/src/model-generator/Kontent.Ai.ModelGenerator.Tests/ProgramTests.cs +++ b/src/model-generator/Kontent.Ai.ModelGenerator.Tests/ProgramTests.cs @@ -2,10 +2,31 @@ public class ProgramTests { + private const string EnvironmentId = "11111111-1111-1111-1111-111111111111"; + [Fact] public async Task CreateCodeGeneratorOptions_NoEnvironmentId_ReturnsError() { var result = await Program.Main(Array.Empty()); result.Should().Be(1); } -} \ No newline at end of file + + // Both of these used to be accepted: the argument that names the mode was dropped in binding, so the + // run continued in the other mode. The first generated a full set of Delivery models over the output + // directory and exited 0, which is a successful-looking run that wrote the wrong files. + [Fact] + public async Task Main_ManagementArgWithoutTheModeSwitch_FailsRatherThanGeneratingDeliveryModels() + { + var result = await Program.Main(["-i", EnvironmentId, "-k", "some-key"]); + + result.Should().Be(1); + } + + [Fact] + public async Task Main_DeliveryArgInManagementMode_FailsRatherThanDroppingIt() + { + var result = await Program.Main(["-m", "-p", EnvironmentId, "-k", "some-key"]); + + result.Should().Be(1); + } +} diff --git a/src/model-generator/Kontent.Ai.ModelGenerator/CommandLine/ArgHelpers.cs b/src/model-generator/Kontent.Ai.ModelGenerator/CommandLine/ArgHelpers.cs index a7749fbbc..aeb55bf6f 100644 --- a/src/model-generator/Kontent.Ai.ModelGenerator/CommandLine/ArgHelpers.cs +++ b/src/model-generator/Kontent.Ai.ModelGenerator/CommandLine/ArgHelpers.cs @@ -21,6 +21,20 @@ internal static class ArgHelpers private static readonly ProgramOptionsData ManagementProgramOptionsData = new(typeof(ManagementOptions), "management-sdk-net"); + /// + /// Generator options only one mode reads, so the generic --<PropertyName> form has to be + /// mode-scoped as well. selects how Delivery read models + /// express nullability; management models are uniformly nullable because null means "leave this + /// element alone" on upsert, so there is nothing there for it to select. + /// + private static readonly IReadOnlySet DeliveryOnlyGeneratorOptions = + new HashSet([nameof(CodeGeneratorOptions.Nullability)], StringComparer.OrdinalIgnoreCase); + + // The section-qualified form of every option, e.g. ManagementOptions:ApiKey. Mode-scoped for the same + // reason the mapping tables are: only the running mode's section is ever read. + private static readonly IReadOnlyList DeliveryOptionKeys = SectionKeysOf(DeliveryProgramOptionsData); + private static readonly IReadOnlyList ManagementOptionKeys = SectionKeysOf(ManagementProgramOptionsData); + /// /// Returns true if -m or --management appears anywhere in . /// @@ -38,16 +52,10 @@ private static bool IsModeSwitch(string arg) => string.Equals(arg, ArgMappingsRegister.ManagementShortFlag, StringComparison.OrdinalIgnoreCase) || string.Equals(arg, ArgMappingsRegister.ManagementLongFlag, StringComparison.OrdinalIgnoreCase); - public static IDictionary GetSwitchMappings(string[] args) - { - var modeMappings = IsManagementMode(args) - ? ArgMappingsRegister.ManagementMappings - : ArgMappingsRegister.DeliveryEnvironmentIdMappings; - - return ArgMappingsRegister.GeneralMappings - .Union(modeMappings) + public static IDictionary GetSwitchMappings(string[] args) => + ArgMappingsRegister.GeneralMappings + .Union(ArgMappingsRegister.ModeMappings(IsManagementMode(args))) .ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.OrdinalIgnoreCase); - } /// /// Returns every problem with the supplied arguments; an empty list means they are usable. @@ -60,29 +68,75 @@ public static IDictionary GetSwitchMappings(string[] args) public static IReadOnlyList FindInvalidArgs(string[] args) { var problems = new List(); + var managementMode = IsManagementMode(args); var codeGeneratorOptionsProperties = typeof(CodeGeneratorOptions).GetProperties() .Where(p => p.PropertyType != DeliveryProgramOptionsData.Type && p.PropertyType != ManagementProgramOptionsData.Type) .Select(p => p.Name) + .Where(name => !managementMode || !DeliveryOnlyGeneratorOptions.Contains(name)) .ToList(); - var brokenArgs = args.Where(a => + foreach (var arg in args.Where(StartsWithArgumentName)) { - if (!StartsWithArgumentName(a)) return false; + var argumentName = SplitArgument(arg).FirstOrDefault() ?? string.Empty; - var argumentName = SplitArgument(a).FirstOrDefault() ?? string.Empty; - return !ArgMappingsRegister.AllMappingsKeys.Contains(argumentName) && - !IsOptionPropertyValid(DeliveryProgramOptionsData, argumentName) && - !IsOptionPropertyValid(ManagementProgramOptionsData, argumentName) && - !IsOptionPropertyValid(codeGeneratorOptionsProperties, argumentName); - }); + if (IsKnownInMode(argumentName, managementMode) || + IsOptionPropertyValid(ModeOptionKeys(managementMode), argumentName) || + IsOptionPropertyValid(codeGeneratorOptionsProperties, argumentName)) + { + continue; + } + + problems.Add(WrongModeProblem(argumentName, managementMode) ?? $"Unsupported parameter: {arg}"); + } - problems.AddRange(brokenArgs.Select(arg => $"Unsupported parameter: {arg}")); problems.AddRange(ValidateEnumArgValues(args)); return problems; } + private static bool IsKnownInMode(string argumentName, bool managementMode) => + IsModeSwitch(argumentName) || + ArgMappingsRegister.GeneralMappings.ContainsKey(argumentName) || + ArgMappingsRegister.ModeMappings(managementMode).ContainsKey(argumentName); + + private static IReadOnlyList ModeOptionKeys(bool managementMode) => + managementMode ? ManagementOptionKeys : DeliveryOptionKeys; + + /// + /// The message for an argument that is real but belongs to the mode that is not running, or + /// null when the argument is not a mode question at all. + /// + /// + /// Binding drops such an argument, so without this it takes effect nowhere and says nothing: omitting + /// --management generated a full set of Delivery models over the output directory and exited 0. + /// + private static string? WrongModeProblem(string argumentName, bool managementMode) + { + if (managementMode) + { + if (IsOptionPropertyValid(DeliveryOnlyGeneratorOptions, argumentName)) + { + return $"{argumentName} applies to Delivery models only. Management models are always " + + "nullable, because a null element is left untouched on upsert."; + } + + return ArgMappingsRegister.DeliveryMappings.ContainsKey(argumentName) || + IsOptionPropertyValid(DeliveryOptionKeys, argumentName) + ? $"{argumentName} configures the Delivery API, which --management does not use." + : null; + } + + return ArgMappingsRegister.ManagementMappings.ContainsKey(argumentName) || + IsOptionPropertyValid(ManagementOptionKeys, argumentName) + ? $"{argumentName} configures the Management API. Add --management (or -m) to generate from it." + : null; + } + + private static IReadOnlyList SectionKeysOf<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T> + (ProgramOptionsData programOptionsData) => + [.. programOptionsData.OptionProperties.Select(prop => $"{programOptionsData.OptionsName}:{prop.Name}")]; + private static IEnumerable ValidateEnumArgValues(string[] args) { var enumKeys = BuildEnumArgKeyMap(); @@ -151,10 +205,6 @@ private static Dictionary BuildEnumArgKeyMap() public static UsedSdkInfo GetUsedSdkInfo(bool managementMode = false) => managementMode ? ManagementProgramOptionsData.UsedSdkInfo : DeliveryProgramOptionsData.UsedSdkInfo; - private static bool IsOptionPropertyValid<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T> - (ProgramOptionsData programOptionsData, string arg) => - IsOptionPropertyValid(programOptionsData.OptionProperties.Select(prop => $"{programOptionsData.OptionsName}:{prop.Name}"), arg); - private static bool IsOptionPropertyValid(IEnumerable optionProperties, string arg) => optionProperties.Any(prop => string.Equals(GetPrefixedMappingName(prop), arg, StringComparison.OrdinalIgnoreCase)); diff --git a/src/model-generator/Kontent.Ai.ModelGenerator/CommandLine/ArgMappingsRegister.cs b/src/model-generator/Kontent.Ai.ModelGenerator/CommandLine/ArgMappingsRegister.cs index 21851d5bd..d05c6cd44 100644 --- a/src/model-generator/Kontent.Ai.ModelGenerator/CommandLine/ArgMappingsRegister.cs +++ b/src/model-generator/Kontent.Ai.ModelGenerator/CommandLine/ArgMappingsRegister.cs @@ -21,15 +21,17 @@ internal static class ArgMappingsRegister { "-o", nameof(CodeGeneratorOptions.OutputDir) }, { "-b", nameof(CodeGeneratorOptions.BaseRecord) }, { "-r", nameof(CodeGeneratorOptions.BaseRecord) }, - { "--nullability", nameof(CodeGeneratorOptions.Nullability) }, }; - public static readonly IDictionary DeliveryEnvironmentIdMappings = new Dictionary(StringComparer.OrdinalIgnoreCase) + public static readonly IDictionary DeliveryMappings = new Dictionary(StringComparer.OrdinalIgnoreCase) { { "-i", $"{nameof(DeliveryOptions)}:{nameof(DeliveryOptions.EnvironmentId)}" }, { "--environmentId", $"{nameof(DeliveryOptions)}:{nameof(DeliveryOptions.EnvironmentId)}" }, { "-p", $"{nameof(DeliveryOptions)}:{nameof(DeliveryOptions.EnvironmentId)}" }, // Backwards compatibility - {"--projectid", $"{nameof(DeliveryOptions)}:{nameof(DeliveryOptions.EnvironmentId)}" } // Backwards compatibility + {"--projectid", $"{nameof(DeliveryOptions)}:{nameof(DeliveryOptions.EnvironmentId)}" }, // Backwards compatibility + // Read-side nullability. Management models are uniformly nullable by contract - null means "leave + // this element alone" on upsert - so there is nothing for this to select there. + { "--nullability", nameof(CodeGeneratorOptions.Nullability) }, }; public static readonly IDictionary ManagementMappings = new Dictionary(StringComparer.OrdinalIgnoreCase) @@ -41,14 +43,11 @@ internal static class ArgMappingsRegister }; /// - /// All recognized argument keys across both modes plus the mode-switch flags themselves - /// (which don't bind to a config property but are still legal). Used by - /// to reject typos early. + /// The mode-specific mappings in effect for . The two tables are not + /// interchangeable - -i targets a different options section in each, and each carries flags the + /// other has no use for - so validation and binding both have to ask for one mode or the other rather + /// than accepting the union. /// - public static readonly ISet AllMappingsKeys = new HashSet( - GeneralMappings.Keys - .Union(DeliveryEnvironmentIdMappings.Keys) - .Union(ManagementMappings.Keys) - .Union([ManagementShortFlag, ManagementLongFlag]), - StringComparer.OrdinalIgnoreCase); + public static IDictionary ModeMappings(bool managementMode) => + managementMode ? ManagementMappings : DeliveryMappings; } diff --git a/src/model-generator/Kontent.Ai.ModelGenerator/CommandLine/ValidationExtensions.cs b/src/model-generator/Kontent.Ai.ModelGenerator/CommandLine/ValidationExtensions.cs index 68f7fbf68..2b621f4c9 100644 --- a/src/model-generator/Kontent.Ai.ModelGenerator/CommandLine/ValidationExtensions.cs +++ b/src/model-generator/Kontent.Ai.ModelGenerator/CommandLine/ValidationExtensions.cs @@ -2,6 +2,7 @@ using Kontent.Ai.Delivery.Abstractions; using Kontent.Ai.Management.Configuration; using Kontent.Ai.ModelGenerator.Core.Configuration; +using Microsoft.CodeAnalysis.CSharp; namespace Kontent.Ai.ModelGenerator.CommandLine; @@ -33,6 +34,7 @@ public static void Validate(this CodeGeneratorOptions codeGeneratorOptions) throw MissingEnvironmentId(nameof(DeliveryOptions.EnvironmentId)); } + ValidateBaseRecord(codeGeneratorOptions); ValidateOptions(codeGeneratorOptions.DeliveryOptions, "delivery"); } @@ -51,9 +53,28 @@ public static void ValidateManagement(this CodeGeneratorOptions codeGeneratorOpt throw MissingEnvironmentId(nameof(ManagementOptions.EnvironmentId)); } + ValidateBaseRecord(codeGeneratorOptions); ValidateOptions(codeGeneratorOptions.ManagementOptions, "management"); } + /// + /// The base record name is written into the generated code verbatim, so anything that is not a C# + /// identifier produces a file that cannot compile - -b "My-Base" emitted + /// public partial record My-Base and an extender deriving every model from it. + /// + private static void ValidateBaseRecord(CodeGeneratorOptions codeGeneratorOptions) + { + var baseRecord = codeGeneratorOptions.BaseRecord; + if (string.IsNullOrEmpty(baseRecord) || SyntaxFacts.IsValidIdentifier(baseRecord)) + { + return; + } + + throw new InvalidOperationException( + $"'{baseRecord}' is not a valid C# record name, so the generated base record would not compile. " + + "Use a name that is a valid C# identifier - letters, digits and underscores, not starting with a digit."); + } + private static InvalidOperationException MissingEnvironmentId(string memberName) => new($"You have to provide the '{memberName}' argument. " + "See http://bit.ly/k-params for more details on configuration."); diff --git a/src/model-generator/Kontent.Ai.ModelGenerator/Program.cs b/src/model-generator/Kontent.Ai.ModelGenerator/Program.cs index 804086ee2..90709c4d7 100644 --- a/src/model-generator/Kontent.Ai.ModelGenerator/Program.cs +++ b/src/model-generator/Kontent.Ai.ModelGenerator/Program.cs @@ -105,6 +105,6 @@ private static Type ConfigureManagementMode(IServiceCollection services, IConfig private static void PrintSdkVersion(bool managementMode) { var usedSdkInfo = ArgHelpers.GetUsedSdkInfo(managementMode); - Messages.LogInfo($"Models were generated for {usedSdkInfo.Name} version {usedSdkInfo.Version}"); + Messages.LogInfo($"Generating models for {usedSdkInfo.Name} version {usedSdkInfo.Version}"); } } diff --git a/src/model-generator/Kontent.Ai.ModelGenerator/appSettings.json b/src/model-generator/Kontent.Ai.ModelGenerator/appSettings.json index ac1a7a6c5..3659a32a8 100644 --- a/src/model-generator/Kontent.Ai.ModelGenerator/appSettings.json +++ b/src/model-generator/Kontent.Ai.ModelGenerator/appSettings.json @@ -14,6 +14,6 @@ // Output directory for the generated files. "OutputDir": "./output", - // If set, a base class is created and all generated records derive from it via partial extender records. - "BaseClass": null + // If set, a base record is created and all generated records derive from it via partial extender records. + "BaseRecord": null } diff --git a/src/model-generator/README.md b/src/model-generator/README.md index 5e465be2d..9f99d9fdb 100644 --- a/src/model-generator/README.md +++ b/src/model-generator/README.md @@ -101,13 +101,23 @@ Latest release: [Download](https://github.com/kontent-ai/dotnet/releases) | `-b`, `-r` | `--baseRecord` | No | `null` | If provided, a base record will be created and all generated records will derive from it via partial extender records | | | `--nullability` | No | `strict` | Either `strict` or `semantic`. Delivery mode only. See [Nullability mode](#nullability-mode). | +A parameter that belongs to the mode you did not ask for is an error, not a silently ignored argument: +`-k` without `-m` fails rather than quietly generating Delivery models over your output directory, and +`--nullability` is refused with `-m` rather than accepted and ignored. This covers the section-qualified +form too, so `--ManagementOptions:ApiKey` without `-m` is rejected the same way `-k` is. Options supplied +through `appSettings.json` are not affected - a config file may carry both sections, and only the section +belonging to the mode you run is read. + ### CLI Syntax Short keys such as `-n "MyModels"` are interchangeable with the long keys `--namespace "MyModels"`. Other possible syntax is `-n=MyModels` or `--namespace=MyModels`. Parameter values are case-insensitive. To see all aspects of the syntax, see the [MS docs](https://docs.microsoft.com/en-us/dotnet/api/microsoft.extensions.configuration.commandlineconfigurationextensions.addcommandline). ### Config file -These parameters can also be set via the `appSettings.json` file located in the same directory as the executable file. Command-line parameters always take precedence. +These parameters can also be set via an `appSettings.json` file in the directory you run the tool from. +Command-line parameters always take precedence. The file is not installed with the tool — copy +[the template](https://github.com/kontent-ai/dotnet/blob/main/src/model-generator/Kontent.Ai.ModelGenerator/appSettings.json) +into your working directory and edit it. ### Advanced configuration (Preview API, Secure API) @@ -118,7 +128,7 @@ There are two ways of configuring advanced Delivery SDK options (such as secure --DeliveryOptions:UseSecureAccess true --DeliveryOptions:SecureAccessApiKey ``` -2. [`appSettings.json`](https://github.com/kontent-ai/dotnet/blob/main/src/model-generator/Kontent.Ai.ModelGenerator/appSettings.json) - suitable for the standalone app release +2. An `appSettings.json` in the directory you run the tool from — see [Config file](#config-file) ## Generated Model Example (Delivery) diff --git a/src/sync/CHANGELOG.md b/src/sync/CHANGELOG.md index cf70273c0..bde72171b 100644 --- a/src/sync/CHANGELOG.md +++ b/src/sync/CHANGELOG.md @@ -5,6 +5,32 @@ Entries before the move to this monorepo were imported from the GitHub Releases ## Unreleased +## 2.0.0-rc.2 (2026-08-12) _(prerelease)_ + +### Fixed + +- **The named-options accessor requires the client name it reads.** It accepted a null name and fell back to the unnamed registration, which only exists when a default client was registered — so on a named-only setup that path would have resolved a `SyncOptions` nobody configured and built requests against a blank environment rather than failing. No caller passed null; the parameter is now non-nullable, matching the Delivery SDK's equivalent. + +- **The pass-through `CreateRefitSettings` wrapper is gone**, along with its summary describing a customization hook that had been removed. + +- **The Refit settings no longer configure a query string this API does not have.** A collection format and URL key formatter were carried over from the Delivery SDK, but the sync endpoints send the environment in the path and the continuation token in a header — there is no query parameter for either setting to apply to. + +- **`SyncClientBuilder`'s remark matches its signature**, which returns the concrete `SyncClient` — that is what makes the client it hands back disposable. + +- **`ChangeType` serializes the value the API sends.** Its own converter took precedence over the SDK's and carried no naming policy, so writing a delta produced `"Changed"` where the wire uses `"changed"` — reading was unaffected, being case-insensitive, so this only surfaced for a consumer re-sending what they had read. Each member now states its wire name. + +- **The client factory no longer relabels an exception that came from your own registration.** `Get(name)` caught `InvalidOperationException` and reported it as a missing client, so a `configureHttpClient` that rejected its input came back as "No sync client registered with name '…'". A genuinely missing registration still says so. + +- **The 1.0 → 2.0 upgrade guide's first paragraph no longer links to a guide that was retired.** + +- **`SyncOptionsBuilder.Build` copies by reflection rather than property by property.** It listed the properties it carried, which keeps compiling when an option is added and silently stops carrying it — a value the caller set that the client never sees. + +- **The `X-KC-SOURCE` header keeps naming the integration that made the call.** Attribution matched the SDK assembly by full name, which carries the version — and nothing pins `AssemblyVersion`, so the reference an integration recorded when it was built stopped matching on the first SDK release after that. The header then went silently missing for every consumer who had not rebuilt. Matching is now by simple name. + +- **A request is bounded again when the SDK's own resilience pipeline is not the one installed.** `HttpClient.Timeout` was set to `Timeout.InfiniteTimeSpan` unconditionally, on the premise that the resilience pipeline owns timing - but the 30-second per-attempt timeout that premise rests on exists only while `EnableResilience` is left on and no `configureResilience` hook replaces the default pipeline. Setting `EnableResilience = false`, or supplying a pipeline that adds no timeout of its own, therefore left a call with no attempt timeout, no overall timeout and no ceiling of any kind, so a connection that stopped responding hung the caller indefinitely. This affected both the container-registered client and the container-free one, which builds its own transport. The ceiling is now lifted only for the default pipeline; otherwise `HttpClient`'s 100-second default applies, as it did in 1.0. A custom pipeline that legitimately needs longer can raise it through `configureHttpClient`. + +- **An attempt the resilience pipeline timed out is now retried instead of failing the whole call.** The default pipeline wraps retry around a 30-second per-attempt timeout, so a hung attempt reaches the retry as Polly's `TimeoutRejectedException` - a type the SDK's transient classifier did not recognise. The single situation that per-attempt timeout exists for, a connection that stops responding and that a fresh attempt would recover from, therefore failed the whole call after 30 seconds with no retries at all. + ## 2.0.0-rc.1 (2026-08-07) _(prerelease)_ Targets .NET 10, moving from `net8.0` to `net10.0`, and Refit's transport is upgraded across four major diff --git a/src/sync/Directory.Build.props b/src/sync/Directory.Build.props index af5d33240..150bdb3f2 100644 --- a/src/sync/Directory.Build.props +++ b/src/sync/Directory.Build.props @@ -4,8 +4,7 @@ - + $(SyncVersion) https://github.com/kontent-ai/dotnet/tree/main/src/sync README.md diff --git a/src/sync/Kontent.Ai.Sync.Tests/ApiApproval/PublicApiApprovalTests.SyncPublicApi_ShouldNotChangeUnexpectedly.verified.txt b/src/sync/Kontent.Ai.Sync.Tests/ApiApproval/PublicApiApprovalTests.SyncPublicApi_ShouldNotChangeUnexpectedly.verified.txt index f27399db0..4da66edd7 100644 --- a/src/sync/Kontent.Ai.Sync.Tests/ApiApproval/PublicApiApprovalTests.SyncPublicApi_ShouldNotChangeUnexpectedly.verified.txt +++ b/src/sync/Kontent.Ai.Sync.Tests/ApiApproval/PublicApiApprovalTests.SyncPublicApi_ShouldNotChangeUnexpectedly.verified.txt @@ -1,13 +1,13 @@ // Kontent.Ai.Sync public enum ApiMode - Preview - Public - Secure + Preview = 1 + Public = 0 + Secure = 2 // Kontent.Ai.Sync public enum ChangeType - Changed - Deleted + Changed = 0 + Deleted = 1 // Kontent.Ai.Sync public interface IError @@ -61,7 +61,7 @@ public interface ISyncResult`1 : ISyncResult T Value { get; } // Kontent.Ai.Sync -public sealed class ServiceCollectionExtensions +public static class ServiceCollectionExtensions static IServiceCollection AddSyncClient(IServiceCollection services, Action configureOptions, Action? configureHttpClient, Action>? configureResilience) static IServiceCollection AddSyncClient(IServiceCollection services, Action configureOptions) static IServiceCollection AddSyncClient(IServiceCollection services, Action configureOptions, Action? configureHttpClient, Action>? configureResilience) @@ -157,7 +157,7 @@ public sealed class SyncOptions : IValidatableObject IEnumerable Validate(ValidationContext validationContext) // Kontent.Ai.Sync -public sealed class SyncOptionsExtensions +public static class SyncOptionsExtensions static String GetBaseUrl(SyncOptions options) static String? GetApiKey(SyncOptions options) diff --git a/src/sync/Kontent.Ai.Sync.Tests/Configuration/SyncOptionsCopyTests.cs b/src/sync/Kontent.Ai.Sync.Tests/Configuration/SyncOptionsCopyTests.cs new file mode 100644 index 000000000..14d1c3345 --- /dev/null +++ b/src/sync/Kontent.Ai.Sync.Tests/Configuration/SyncOptionsCopyTests.cs @@ -0,0 +1,61 @@ +using System.Reflection; +using AwesomeAssertions; +using Kontent.Ai.Common; +using Kontent.Ai.Sync.Configuration; + +namespace Kontent.Ai.Sync.Tests.Configuration; + +/// +/// used to list the properties it carried, which keeps compiling +/// when an option is added and silently stops carrying it. Reflected here for the same reason the build +/// is: naming them would leave a new one uncovered in exactly the case that matters. +/// +public class SyncOptionsCopyTests +{ + [Fact] + public void Copy_CarriesEveryWritableProperty() + { + var source = new SyncOptions(); + var writable = typeof(SyncOptions) + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(p => p is { CanRead: true, CanWrite: true }) + .ToList(); + + writable.Should().NotBeEmpty(); + foreach (var property in writable) + { + property.SetValue(source, DistinctValueFor(property)); + } + + var target = new SyncOptions(); + OptionsCopier.Copy(source, target); + + foreach (var property in writable) + { + property.GetValue(target).Should().Be(property.GetValue(source), property.Name); + } + } + + [Fact] + public void Build_CarriesWhatTheBuilderWasTold() + { + var built = SyncOptionsBuilder.CreateInstance() + .WithEnvironmentId("11111111-1111-1111-1111-111111111111") + .UsePreviewApi("preview-key") + .Build(); + + built.EnvironmentId.Should().Be("11111111-1111-1111-1111-111111111111"); + built.ApiKey.Should().Be("preview-key"); + built.ApiMode.Should().Be(ApiMode.Preview); + } + + // A value that differs from the property's default, so a property the copy skips fails the comparison. + private static object DistinctValueFor(PropertyInfo property) => property.PropertyType switch + { + var t when t == typeof(string) => $"copied-{property.Name}", + var t when t == typeof(bool) => true, + var t when t.IsEnum => Enum.GetValues(t).GetValue(Enum.GetValues(t).Length - 1)!, + _ => throw new NotSupportedException( + $"{property.Name} is a {property.PropertyType.Name}; add a distinct value for it here."), + }; +} diff --git a/src/sync/Kontent.Ai.Sync.Tests/Extensions/ServiceCollectionExtensionsTests.cs b/src/sync/Kontent.Ai.Sync.Tests/Extensions/ServiceCollectionExtensionsTests.cs index 8624a4212..ba7999c2f 100644 --- a/src/sync/Kontent.Ai.Sync.Tests/Extensions/ServiceCollectionExtensionsTests.cs +++ b/src/sync/Kontent.Ai.Sync.Tests/Extensions/ServiceCollectionExtensionsTests.cs @@ -1,4 +1,6 @@ using AwesomeAssertions; +using Polly; +using Polly.Retry; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -206,4 +208,55 @@ public void AddSyncClient_RuntimeConfigurationChanges_AreReflectedInOptions() options2.ApiMode.Should().Be(ApiMode.Preview); options2.ApiKey.Should().Be("new-preview-key"); } + + // The HttpClient ceiling is the only thing bounding a request when the default resilience pipeline - + // which carries the per-attempt timeout - is not the one installed. + + [Fact] + public void AddSyncClient_DefaultResilience_LiftsTheHttpClientCeiling() + { + var timeout = ResolveHttpClientTimeout(services => + services.AddSyncClient("production", options => options.EnvironmentId = EnvironmentId)); + + timeout.Should().Be(Timeout.InfiniteTimeSpan); + } + + [Fact] + public void AddSyncClient_ResilienceDisabled_StillBoundsTheRequest() + { + var timeout = ResolveHttpClientTimeout(services => + services.AddSyncClient("production", options => + { + options.EnvironmentId = EnvironmentId; + options.EnableResilience = false; + })); + + timeout.Should().NotBe(Timeout.InfiniteTimeSpan); + timeout.Should().BeGreaterThan(TimeSpan.Zero); + } + + [Fact] + public void AddSyncClient_CustomResilience_StillBoundsTheRequest() + { + var timeout = ResolveHttpClientTimeout(services => + services.AddSyncClient( + "production", + options => options.EnvironmentId = EnvironmentId, + configureResilience: builder => builder.AddRetry(new RetryStrategyOptions()))); + + timeout.Should().NotBe(Timeout.InfiniteTimeSpan); + timeout.Should().BeGreaterThan(TimeSpan.Zero); + } + + private static TimeSpan ResolveHttpClientTimeout(Action register) + { + var services = new ServiceCollection(); + register(services); + + using var provider = services.BuildServiceProvider(); + using var httpClient = provider.GetRequiredService() + .CreateClient("Kontent.Ai.Sync.HttpClient.production"); + + return httpClient.Timeout; + } } diff --git a/src/sync/Kontent.Ai.Sync.Tests/Handlers/ResiliencePipelineTests.cs b/src/sync/Kontent.Ai.Sync.Tests/Handlers/ResiliencePipelineTests.cs index 5c5cf18a9..13808d93d 100644 --- a/src/sync/Kontent.Ai.Sync.Tests/Handlers/ResiliencePipelineTests.cs +++ b/src/sync/Kontent.Ai.Sync.Tests/Handlers/ResiliencePipelineTests.cs @@ -5,6 +5,7 @@ using Kontent.Ai.Common.Http; using Polly; using Polly.Retry; +using Polly.Timeout; namespace Kontent.Ai.Sync.Tests.Handlers; @@ -170,6 +171,34 @@ public async Task ConfigureDefaultResilience_DoesNotRetryOnNonRetryableStatusCod attempts.Should().Be(1); } + [Fact] + public void IsTransientException_TimeoutRejectedException_ReturnsTrue() + { + HttpRetryPredicates.IsTransientException(new TimeoutRejectedException(), CancellationToken.None) + .Should().BeTrue(); + } + + [Fact] + public async Task ConfigureDefaultResilience_RetriesAnAttemptTheTimeoutRejected() + { + var builder = new ResiliencePipelineBuilder(); + ServiceCollectionExtensions.ConfigureDefaultResilience(builder); + var pipeline = builder.Build(); + + var attempts = 0; + var response = await pipeline.ExecuteAsync(_ => + { + attempts++; + // What the inner timeout strategy surfaces to the retry when an attempt hangs. + return attempts < 2 + ? throw new TimeoutRejectedException() + : ValueTask.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + attempts.Should().Be(2); + } + private static HttpResponseMessage WithRetryAfter(HttpResponseMessage response, TimeSpan delta) { response.Headers.RetryAfter = new RetryConditionHeaderValue(delta); diff --git a/src/sync/Kontent.Ai.Sync.Tests/Handlers/SyncAuthenticationHandlerTests.cs b/src/sync/Kontent.Ai.Sync.Tests/Handlers/SyncAuthenticationHandlerTests.cs index 11c8d113f..542b3b280 100644 --- a/src/sync/Kontent.Ai.Sync.Tests/Handlers/SyncAuthenticationHandlerTests.cs +++ b/src/sync/Kontent.Ai.Sync.Tests/Handlers/SyncAuthenticationHandlerTests.cs @@ -1,4 +1,5 @@ using AwesomeAssertions; +using Kontent.Ai.Common; using Kontent.Ai.Sync.Configuration; using Kontent.Ai.Sync.Handlers; using Microsoft.Extensions.Options; @@ -46,7 +47,7 @@ public async Task SendAsync_WhenApiKeyBecomesEmpty_ClearsAuthorizationHeader() }; var monitor = new TestOptionsMonitor(withKey); - var handler = new SyncAuthenticationHandler(new MonitorBackedSyncOptionsAccessor(monitor, optionsName: null)) + var handler = new SyncAuthenticationHandler(new MonitorBackedSyncOptionsAccessor(monitor, NamedClients.Default)) { InnerHandler = new TestHandler() }; @@ -213,7 +214,7 @@ public async Task SendAsync_DoesNotModifyPath() private static SyncAuthenticationHandler CreateHandler(SyncOptions options) { var monitor = new TestOptionsMonitor(options); - return new SyncAuthenticationHandler(new MonitorBackedSyncOptionsAccessor(monitor, optionsName: null)) + return new SyncAuthenticationHandler(new MonitorBackedSyncOptionsAccessor(monitor, NamedClients.Default)) { InnerHandler = new TestHandler() }; diff --git a/src/sync/Kontent.Ai.Sync.Tests/Models/ChangeTypeSerializationTests.cs b/src/sync/Kontent.Ai.Sync.Tests/Models/ChangeTypeSerializationTests.cs new file mode 100644 index 000000000..ad1a7f95a --- /dev/null +++ b/src/sync/Kontent.Ai.Sync.Tests/Models/ChangeTypeSerializationTests.cs @@ -0,0 +1,31 @@ +using System.Text.Json; +using AwesomeAssertions; +using Kontent.Ai.Sync.Configuration; + +namespace Kontent.Ai.Sync.Tests.Models; + +/// +/// A delta read from the API and written back out has to produce the value the API sends. The type-level +/// converter takes precedence over the one the SDK configures and carried no naming policy, so writing +/// produced "Changed" against the wire's "changed" - readable, but not round-trippable. +/// +public class ChangeTypeSerializationTests +{ + [Theory] + [InlineData(ChangeType.Changed, "changed")] + [InlineData(ChangeType.Deleted, "deleted")] + public void Write_UsesTheWireName(ChangeType value, string expected) + { + JsonSerializer.Serialize(value).Should().Be($"\"{expected}\""); + JsonSerializer.Serialize(value, RefitSettingsProvider.CreateDefaultJsonSerializerOptions()) + .Should().Be($"\"{expected}\""); + } + + [Theory] + [InlineData("changed", ChangeType.Changed)] + [InlineData("deleted", ChangeType.Deleted)] + public void Read_AcceptsTheWireName(string wire, ChangeType expected) + { + JsonSerializer.Deserialize($"\"{wire}\"").Should().Be(expected); + } +} diff --git a/src/sync/Kontent.Ai.Sync.Tests/StandaloneClientTests.cs b/src/sync/Kontent.Ai.Sync.Tests/StandaloneClientTests.cs index bdb33c421..413b1267b 100644 --- a/src/sync/Kontent.Ai.Sync.Tests/StandaloneClientTests.cs +++ b/src/sync/Kontent.Ai.Sync.Tests/StandaloneClientTests.cs @@ -1,5 +1,7 @@ using System.Net; using AwesomeAssertions; +using Kontent.Ai.Sync.Api; +using Kontent.Ai.Sync.Configuration; using Polly; using Polly.Retry; using RichardSzalay.MockHttp; @@ -187,4 +189,34 @@ public void InvalidOptions_AreRejectedAtConstruction() act.Should().Throw(); } + + // The container-free client owns its HttpClient, so the ceiling that bounds a request is set here. + // Only the default pipeline, which times out each attempt itself, earns having it lifted. + + [Fact] + public void CreateHttpClient_DefaultResilience_LiftsTheHttpClientCeiling() + { + using var httpClient = BuildHttpClient(pipelineBoundsAttempts: true); + + httpClient.Timeout.Should().Be(Timeout.InfiniteTimeSpan); + } + + [Fact] + public void CreateHttpClient_WithoutTheDefaultPipeline_StillBoundsTheRequest() + { + using var httpClient = BuildHttpClient(pipelineBoundsAttempts: false); + + httpClient.Timeout.Should().NotBe(Timeout.InfiniteTimeSpan); + httpClient.Timeout.Should().BeGreaterThan(TimeSpan.Zero); + } + + private static HttpClient BuildHttpClient(bool pipelineBoundsAttempts) + { + var options = Options(); + + return SyncApiFactory.CreateHttpClient( + options, + new SnapshotSyncOptionsAccessor(options), + pipelineBoundsAttempts: pipelineBoundsAttempts); + } } diff --git a/src/sync/Kontent.Ai.Sync/Api/SyncApiFactory.cs b/src/sync/Kontent.Ai.Sync/Api/SyncApiFactory.cs index 18d5d2ba1..5d580a189 100644 --- a/src/sync/Kontent.Ai.Sync/Api/SyncApiFactory.cs +++ b/src/sync/Kontent.Ai.Sync/Api/SyncApiFactory.cs @@ -18,11 +18,14 @@ internal static class SyncApiFactory /// [resilience] → tracking → auth → primary - matching the DI path's ordering so each retry /// re-runs tracking and auth fresh. Resilience is included only when /// is supplied; overrides the default . + /// says whether that pipeline is the SDK's default one, which + /// carries the per-attempt timeout - only that one earns the removal of 's ceiling. /// public static HttpClient CreateHttpClient( SyncOptions options, ISyncOptionsAccessor optionsAccessor, ResiliencePipeline? resiliencePipeline = null, + bool pipelineBoundsAttempts = false, ILoggerFactory? loggerFactory = null, HttpMessageHandler? primaryHandler = null) { @@ -44,13 +47,19 @@ public static HttpClient CreateHttpClient( ? tracking : new ResilienceHandler(resiliencePipeline) { InnerHandler = tracking }; - return new HttpClient(outermost) + var httpClient = new HttpClient(outermost) { BaseAddress = new Uri(options.GetBaseUrl(), UriKind.Absolute), - - // Matches the DI path: the resilience pipeline bounds each attempt, so HttpClient's own - // 100-second ceiling on the whole call would only clip it. - Timeout = System.Threading.Timeout.InfiniteTimeSpan, }; + + // Matches the DI path: HttpClient's 100-second ceiling covers the whole call, retries and backoff + // included, so it is lifted only for the default pipeline - the one that bounds each attempt itself. + // In every other case it is all that stops a black-holed connection from hanging the caller forever. + if (pipelineBoundsAttempts) + { + httpClient.Timeout = System.Threading.Timeout.InfiniteTimeSpan; + } + + return httpClient; } } diff --git a/src/sync/Kontent.Ai.Sync/Configuration/RefitSettingsProvider.cs b/src/sync/Kontent.Ai.Sync/Configuration/RefitSettingsProvider.cs index 37d5f49c9..c5d46ce6a 100644 --- a/src/sync/Kontent.Ai.Sync/Configuration/RefitSettingsProvider.cs +++ b/src/sync/Kontent.Ai.Sync/Configuration/RefitSettingsProvider.cs @@ -16,11 +16,12 @@ public static RefitSettings CreateDefaultSettings() { var jsonSerializerOptions = CreateDefaultJsonSerializerOptions(); + // Only the serializer: ISyncApi sends nothing in a query string - the environment travels in the + // path and the continuation token in a header - so the collection format and key formatter that + // the Delivery settings carry would configure something that never happens. return new RefitSettings { ContentSerializer = new SystemTextJsonContentSerializer(jsonSerializerOptions), - CollectionFormat = CollectionFormat.Multi, - UrlParameterKeyFormatter = new CamelCaseUrlParameterKeyFormatter() }; } diff --git a/src/sync/Kontent.Ai.Sync/Configuration/SyncClientBuilder.cs b/src/sync/Kontent.Ai.Sync/Configuration/SyncClientBuilder.cs index bf714d71e..84d7865b0 100644 --- a/src/sync/Kontent.Ai.Sync/Configuration/SyncClientBuilder.cs +++ b/src/sync/Kontent.Ai.Sync/Configuration/SyncClientBuilder.cs @@ -8,8 +8,9 @@ namespace Kontent.Ai.Sync.Configuration; /// /// /// -/// The built client owns its underlying and is returned as a plain -/// - there is no container to reach into. Dispose it when you are done. +/// The built client owns its underlying - there is no container to reach into - +/// so returns the concrete , which is disposable. Dispose it +/// when you are done. /// /// /// For applications using dependency injection, prefer services.AddSyncClient(...), which hands diff --git a/src/sync/Kontent.Ai.Sync/Configuration/SyncOptionsAccessor.cs b/src/sync/Kontent.Ai.Sync/Configuration/SyncOptionsAccessor.cs index 34dcbd58d..46bc23cfc 100644 --- a/src/sync/Kontent.Ai.Sync/Configuration/SyncOptionsAccessor.cs +++ b/src/sync/Kontent.Ai.Sync/Configuration/SyncOptionsAccessor.cs @@ -20,10 +20,16 @@ internal interface ISyncOptionsAccessor /// Reads named options from an , so configuration changes take /// effect without rebuilding the client. /// -internal sealed class MonitorBackedSyncOptionsAccessor(IOptionsMonitor monitor, string? optionsName) +/// +/// The name is required. A null one would have read the unnamed registration, which exists only when a +/// default client was registered - so for a named-only setup it would have resolved a +/// nobody configured, and the handler chain would have built requests against a blank environment instead +/// of failing. Every caller already knows its client name. +/// +internal sealed class MonitorBackedSyncOptionsAccessor(IOptionsMonitor monitor, string optionsName) : ISyncOptionsAccessor { - public SyncOptions Current => optionsName is null ? monitor.CurrentValue : monitor.Get(optionsName); + public SyncOptions Current => monitor.Get(optionsName); } /// diff --git a/src/sync/Kontent.Ai.Sync/Configuration/SyncOptionsBuilder.cs b/src/sync/Kontent.Ai.Sync/Configuration/SyncOptionsBuilder.cs index 49162cbf7..9dc11befb 100644 --- a/src/sync/Kontent.Ai.Sync/Configuration/SyncOptionsBuilder.cs +++ b/src/sync/Kontent.Ai.Sync/Configuration/SyncOptionsBuilder.cs @@ -1,3 +1,5 @@ +using Kontent.Ai.Common; + namespace Kontent.Ai.Sync.Configuration; /// @@ -85,13 +87,15 @@ private void SetCustomEndpoint(string endpoint) } /// - public SyncOptions Build() => new() + /// + /// Reflected rather than listed property by property: a hand-written list keeps compiling when a new + /// option is added and silently stops carrying it, so a value the caller set never reaches the client. + /// + public SyncOptions Build() { - EnvironmentId = _options.EnvironmentId, - EnableResilience = _options.EnableResilience, - ProductionEndpoint = _options.ProductionEndpoint, - PreviewEndpoint = _options.PreviewEndpoint, - ApiKey = _options.ApiKey, - ApiMode = _options.ApiMode - }; + var built = new SyncOptions(); + OptionsCopier.Copy(_options, built); + + return built; + } } diff --git a/src/sync/Kontent.Ai.Sync/Extensions/ServiceCollectionExtensions.cs b/src/sync/Kontent.Ai.Sync/Extensions/ServiceCollectionExtensions.cs index 92ac3a87d..08e2e54e9 100644 --- a/src/sync/Kontent.Ai.Sync/Extensions/ServiceCollectionExtensions.cs +++ b/src/sync/Kontent.Ai.Sync/Extensions/ServiceCollectionExtensions.cs @@ -380,7 +380,7 @@ private static void RegisterNamedHttpClient( Action? configureHttpClient, Action>? configureResilience) { - var refitSettings = CreateRefitSettings(); + var refitSettings = RefitSettingsProvider.CreateDefaultSettings(); var httpClientName = GetHttpClientName(name); var httpClientBuilder = services @@ -391,11 +391,17 @@ private static void RegisterNamedHttpClient( var options = optionsMonitor.Get(name); httpClient.BaseAddress = new Uri(options.GetBaseUrl(), UriKind.Absolute); - // The resilience pipeline owns timing: it bounds each attempt (see ConfigureDefaultResilience) - // and therefore the whole call. HttpClient's own 100-second default applies to the entire - // SendAsync - retries and backoff included - so it silently clipped the last attempt of a - // pipeline that is allowed to take longer than that. - httpClient.Timeout = System.Threading.Timeout.InfiniteTimeSpan; + // Timing is the pipeline's job only when the pipeline is the SDK's own: that one bounds every + // attempt (see ConfigureDefaultResilience), while HttpClient's 100-second ceiling covers the + // whole SendAsync - retries and backoff included - and so would clip a pipeline legitimately + // allowed to run longer. Nothing else bounds a request, so with resilience disabled, or with a + // caller-supplied pipeline whose shape we cannot know, that ceiling stays and a black-holed + // connection fails rather than hanging the caller forever. A caller who needs longer than the + // ceiling raises it through configureHttpClient, which is applied after this. + if (options.EnableResilience && configureResilience is null) + { + httpClient.Timeout = System.Threading.Timeout.InfiniteTimeSpan; + } }); ConfigureResilienceHandler(httpClientBuilder, $"sync_{name}", name, configureResilience); @@ -428,14 +434,6 @@ private static void ConfigureConnectionRecycling(IHttpClientBuilder httpClientBu PooledConnectionLifetime = TimeSpan.FromMinutes(2), }); - /// - /// Creates and configures Refit settings with optional customization. - /// - private static RefitSettings CreateRefitSettings() - { - var refitSettings = RefitSettingsProvider.CreateDefaultSettings(); - return refitSettings; - } /// /// Configures the resilience handler for an HTTP client. diff --git a/src/sync/Kontent.Ai.Sync/GlobalUsings.cs b/src/sync/Kontent.Ai.Sync/GlobalUsings.cs index 222c4e062..c89d16339 100644 --- a/src/sync/Kontent.Ai.Sync/GlobalUsings.cs +++ b/src/sync/Kontent.Ai.Sync/GlobalUsings.cs @@ -1,7 +1 @@ -global using System; -global using System.Collections.Generic; -global using System.Linq; -global using System.Net.Http; -global using System.Threading; -global using System.Threading.Tasks; global using Refit; diff --git a/src/sync/Kontent.Ai.Sync/Models/ChangeType.cs b/src/sync/Kontent.Ai.Sync/Models/ChangeType.cs index f7194c332..eccb55cf4 100644 --- a/src/sync/Kontent.Ai.Sync/Models/ChangeType.cs +++ b/src/sync/Kontent.Ai.Sync/Models/ChangeType.cs @@ -5,16 +5,25 @@ namespace Kontent.Ai.Sync; /// /// Represents the type of change that occurred to a synchronized entity. /// -[JsonConverter(typeof(JsonStringEnumConverter))] +/// +/// Each member states its wire name rather than relying on a naming policy. The converter named here takes +/// precedence over the one the SDK configures, and it carries no policy - so serializing a delta wrote +/// "Changed" where the API sends "changed", and a consumer who re-sent what they had read +/// produced something the API does not accept. Deserialization was unaffected, being case-insensitive, +/// which is why nothing failed on the way in. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] public enum ChangeType { /// /// The entity was added or modified. /// + [JsonStringEnumMemberName("changed")] Changed, /// /// The entity was deleted. /// + [JsonStringEnumMemberName("deleted")] Deleted } diff --git a/src/sync/Kontent.Ai.Sync/SyncClient.cs b/src/sync/Kontent.Ai.Sync/SyncClient.cs index 16c42d43c..d499f1bbb 100644 --- a/src/sync/Kontent.Ai.Sync/SyncClient.cs +++ b/src/sync/Kontent.Ai.Sync/SyncClient.cs @@ -53,10 +53,15 @@ internal SyncClient( Validator.ValidateObject(options, new ValidationContext(options), validateAllProperties: true); _optionsAccessor = new SnapshotSyncOptionsAccessor(options); + + // Only the default pipeline is known to bound each attempt, so only it lifts HttpClient's ceiling. + var usesDefaultResilience = options.EnableResilience && configureResilience is null; + _ownedHttpClient = SyncApiFactory.CreateHttpClient( options, _optionsAccessor, BuildResiliencePipeline(options, configureResilience), + usesDefaultResilience, loggerFactory, primaryHandler); diff --git a/src/sync/Kontent.Ai.Sync/SyncClientFactory.cs b/src/sync/Kontent.Ai.Sync/SyncClientFactory.cs index 14cb3ad49..064713a46 100644 --- a/src/sync/Kontent.Ai.Sync/SyncClientFactory.cs +++ b/src/sync/Kontent.Ai.Sync/SyncClientFactory.cs @@ -16,15 +16,12 @@ public ISyncClient Get(string name) { ArgumentException.ThrowIfNullOrWhiteSpace(name); - try - { - return serviceProvider.GetRequiredKeyedService(name); - } - catch (InvalidOperationException ex) - { - throw new InvalidOperationException( - $"No sync client registered with name '{name}'. Ensure you've registered the client using AddSyncClient(\"{name}\", ...).", - ex); - } + // Resolved with the nullable overload rather than catching: the client's own registration runs + // inside resolution, and anything it throws is also an InvalidOperationException - a + // configureHttpClient that rejected its input used to come back relabelled as a missing + // registration, pointing at the wrong thing entirely. + return serviceProvider.GetKeyedService(name) + ?? throw new InvalidOperationException( + $"No sync client registered with name '{name}'. Ensure you've registered the client using AddSyncClient(\"{name}\", ...)."); } } diff --git a/src/sync/README.md b/src/sync/README.md index 11e5c8825..5a48af38b 100644 --- a/src/sync/README.md +++ b/src/sync/README.md @@ -200,6 +200,12 @@ services.AddSyncClient( configureResilience: builder => builder.AddRetry(new HttpRetryStrategyOptions { MaxRetryAttempts = 5 })); ``` +The default pipeline bounds each attempt at 30 seconds and then retries, which can legitimately outlast +`HttpClient`'s own 100-second ceiling on the whole call - retries and backoff included - so that ceiling +is lifted while the default pipeline is the one installed. Set `EnableResilience = false`, or replace the +pipeline through `configureResilience`, and the ceiling applies again: nothing else would bound the +request. Raise it with `configureHttpClient`, which runs after the SDK's own configuration. + ### Options from other registered services When the options depend on something else in the container — a secret store, a tenant resolver — use the diff --git a/src/sync/docs/upgrade-guide-1.0-to-2.0.md b/src/sync/docs/upgrade-guide-1.0-to-2.0.md index e10519ea6..c72d7d09c 100644 --- a/src/sync/docs/upgrade-guide-1.0-to-2.0.md +++ b/src/sync/docs/upgrade-guide-1.0-to-2.0.md @@ -2,8 +2,8 @@ This guide covers upgrading from `Kontent.Ai.Sync` **1.0.0** to **2.0.0**. -Coming from the sync functionality that used to live in `Kontent.Ai.Delivery`? Read -[the standalone-SDK guide](upgrade-guide.md) first — it covers that move, and this guide picks up after it. +Coming from the sync functionality that used to live in `Kontent.Ai.Delivery`? Move to +`Kontent.Ai.Sync` 1.0 first — it is the same API under a new package — and then follow this guide. Two changes account for nearly all the work: the framework moves to .NET 10, and paging through the sync feed is now a stream you enumerate rather than a call that returns everything at once. The rest is diff --git a/src/testing/PublicApiApproval.cs b/src/testing/PublicApiApproval.cs index 8eddc0fef..6cdf278d2 100644 --- a/src/testing/PublicApiApproval.cs +++ b/src/testing/PublicApiApproval.cs @@ -1,3 +1,4 @@ +using System.Globalization; // Shared test source, compiled into each test assembly - see src/testing/README.md. using System.Reflection; @@ -51,7 +52,7 @@ internal static string Surface(Assembly assembly) private static string TypeSignature(Type type) { - var modifiers = type.IsSealed && !type.IsValueType ? "sealed " : ""; + var modifiers = TypeModifiers(type); var generic = type.IsGenericType ? $"<{string.Join(", ", type.GetGenericArguments().Select(argument => argument.Name))}>" : string.Empty; @@ -59,6 +60,30 @@ private static string TypeSignature(Type type) return $"public {modifiers}{Kind(type)} {type.Name}{generic}{BaseTypes(type)}"; } + /// + /// Whether the type can be instantiated, derived from, or neither. + /// + /// + /// A static class is abstract and sealed in metadata, so a plain sealed check rendered it + /// as a sealed class - indistinguishable from one a caller can hold an instance of. Making a type + /// static, or abstract, or dropping either, changes what a consumer may write. + /// + private static string TypeModifiers(Type type) + { + if (type.IsValueType || type.IsInterface || type.IsEnum) + { + return string.Empty; + } + + return type switch + { + { IsAbstract: true, IsSealed: true } => "static ", + { IsAbstract: true } => "abstract ", + { IsSealed: true } => "sealed ", + _ => string.Empty, + }; + } + private static string Kind(Type type) => type switch { { IsInterface: true } => "interface", @@ -93,7 +118,14 @@ private static IEnumerable Members(Type type) { if (type.IsEnum) { - return Enum.GetNames(type).OrderBy(name => name, StringComparer.Ordinal); + // With the value: renumbering a member is a binary- and wire-breaking change that leaves the + // names identical, so a name-only snapshot would not show it. + var underlying = Enum.GetUnderlyingType(type); + + return Enum.GetNames(type) + .OrderBy(name => name, StringComparer.Ordinal) + .Select(name => + $"{name} = {Convert.ChangeType(Enum.Parse(type, name), underlying, CultureInfo.InvariantCulture)}"); } return Fields(type) @@ -179,7 +211,21 @@ private static IEnumerable Methods(Type type) => type .OrderBy(rendered => rendered, StringComparer.Ordinal); private static string Parameters(MethodBase method) => string.Join(", ", method.GetParameters() - .Select(parameter => $"{TypeName(parameter.ParameterType, Nullability.Create(parameter))} {parameter.Name}")); + .Select(parameter => + $"{RefKind(parameter)}{TypeName(parameter.ParameterType, Nullability.Create(parameter))} {parameter.Name}")); + + /// + /// How the argument is passed. Part of the signature a caller has to write, and the three kinds are + /// otherwise indistinguishable in the snapshot. + /// + private static string RefKind(ParameterInfo parameter) => parameter.ParameterType.IsByRef + ? parameter switch + { + { IsOut: true } => "out ", + { IsIn: true } => "in ", + _ => "ref ", + } + : string.Empty; /// /// A required member is part of the contract: dropping it silently stops obliging callers to @@ -208,6 +254,10 @@ private static string TypeName(Type type, NullabilityInfo? nullability = null) var rendered = type switch { + // A byref type's own Name is the mangled "IEnumerable`1&", which loses the generic arguments - + // so changing an out parameter's element type used to leave the snapshot untouched. The ref + // kind itself is rendered with the parameter, where in/out/ref can be told apart. + { IsByRef: true } => TypeName(type.GetElementType()!, nullability), { IsArray: true } => $"{TypeName(type.GetElementType()!, nullability?.ElementType)}[]", { IsGenericType: true } => RenderGeneric(type, nullability), _ => type.Name,