Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/management/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,16 @@ Entries before the move to this monorepo were imported from the GitHub Releases

- **A page call for the async validation task issues.** `ListAsyncValidationTaskIssuesPageAsync` joins the materialized `ListAsyncValidationTaskIssuesAsync`. This is the listing that scales hardest — a task over a broken environment reports issues proportional to items × variants × elements — and it was the one unbounded listing with no paged access at all, while narrower ones (the variant listings, assets, items) already had it.
- **`ListingPage<T>`**, the page a `List…PageAsync` call returns: the page's `Items`, and the `ContinuationToken` that fetches the next one (`null` on the last page). It is a sealed class rather than a record: its only reference-typed member is a list, so synthesised equality would have compared that by reference and quietly reported two pages holding identical items as unequal. It stays immutable; it just does not claim value semantics it cannot deliver.
- **Typed listings.** `ListLanguageVariantsByItemAsync<T>`, `ListLanguageVariantsByTypeAsync<T>`, `ListLanguageVariantsOfContentTypeWithComponentsAsync<T>` and the `…PageAsync<T>` page calls of the last two project every variant onto the generated record, returning a `LanguageVariantModel<T>` per variant the way the typed get does. They cover the listings whose variants share one content type; a listing by collection or by space mixes types and stays untyped, and `client.ToTyped<T>(variant)` projects one of its variants once its type is known — the same projection, on any fetched `LanguageVariantModel`.

### Changed

- **Refit moves to 15.2.0, and the `Microsoft.Extensions.*` packages to 10.0.11 with it.** Refit 15 adds a keyed registration for source-generated clients, which is the one registration the SDK had to hand-roll and now uses instead; nothing else in the release touches what the SDK uses, and the whole test suite passes on it unchanged. The package's Refit dependency floor moves accordingly, so an application that pins Refit 14 alongside this package must move to 15 as well.

- **A single-choice element maps to `TEnum?` on a generated record.** The model generator emits `TEnum?` where the element allows one option and `IEnumerable<TEnum>?` where it allows several; the converter reads the selected option into either shape and writes a `TEnum?` as a one-element array. Records that declare `IEnumerable<TEnum>` for a single-choice element keep working.

- **A typed read that matches nothing throws.** Projecting a response onto a record none of whose element ids appear in it returned an empty record, so a variant read through a model generated for another environment, or for another content type, looked like a variant with nothing set. It now throws `InvalidOperationException` naming the record. Elements the record does not know are still skipped, so a record generated before a type gained an element keeps working. The typed write path also builds the request's elements directly instead of serializing the record to JSON and reading it back; the wire is unchanged.

### Fixed

- **Standalone clients are built through the same registration as container-resolved ones.** The container-free client assembled a second copy of the HTTP pipeline by hand. `ManagementClient.Create` and the `ManagementClient(ManagementOptions)` constructor run the same `AddManagementClient` registration inside a private container the built client owns, so a standalone client gets what the container path already had: a bounded connection lifetime, so a long-running singleton picks up DNS changes, and the HTTP client factory's diagnostics when logging is configured. The client owns the container it was built over rather than `HttpClient`s of its own — disposing it still fails every further request. Two differences a consumer can observe. Pooled connections now close when the factory releases the handler rather than at the moment of disposal, which is how a container-resolved client has always behaved. And the `ManagementOptions` instance handed to `Create(options)` or to the constructor is copied into the container, as `AddManagementClient(ManagementOptions)` has always done, so a change made to that instance afterwards no longer reaches the client - previously the standalone client read the caller's object on every request, so an API key rotated on it took effect on the next call. That was never documented and neither the container path nor the Delivery SDK's standalone client did it; to rotate a key, build a new client or register through a container and reconfigure the options there.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ public interface IManagementClient
Task<IManagementResult<IReadOnlyList<EnvironmentRoleModel>>> ListEnvironmentRolesAsync(CancellationToken cancellationToken)
Task<IManagementResult<IReadOnlyList<ItemWithVariantFilterResultModel>>> ListItemsWithVariantsByFilterAsync(ItemWithVariantFilterRequestModel filterRequest, CancellationToken cancellationToken)
Task<IManagementResult<IReadOnlyList<LanguageModel>>> ListLanguagesAsync(CancellationToken cancellationToken)
Task<IManagementResult<IReadOnlyList<LanguageVariantModel<T>>>> ListLanguageVariantsByItemAsync<T>(Reference identifier, CancellationToken cancellationToken)
Task<IManagementResult<IReadOnlyList<LanguageVariantModel<T>>>> ListLanguageVariantsByTypeAsync<T>(Reference identifier, CancellationToken cancellationToken)
Task<IManagementResult<IReadOnlyList<LanguageVariantModel<T>>>> ListLanguageVariantsOfContentTypeWithComponentsAsync<T>(Reference identifier, CancellationToken cancellationToken)
Task<IManagementResult<IReadOnlyList<LanguageVariantModel>>> ListLanguageVariantsByCollectionAsync(Reference identifier, CancellationToken cancellationToken)
Task<IManagementResult<IReadOnlyList<LanguageVariantModel>>> ListLanguageVariantsByItemAsync(Reference identifier, CancellationToken cancellationToken)
Task<IManagementResult<IReadOnlyList<LanguageVariantModel>>> ListLanguageVariantsBySpaceAsync(Reference identifier, CancellationToken cancellationToken)
Expand All @@ -79,6 +82,8 @@ public interface IManagementClient
Task<IManagementResult<ListingPage<ContentItemModel>>> ListContentItemsPageAsync(String? continuationToken, CancellationToken cancellationToken)
Task<IManagementResult<ListingPage<ContentItemWithVariantModel>>> ListItemsWithVariantsByBulkGetPageAsync(ItemWithVariantBulkGetRequestModel bulkGetRequest, String? continuationToken, CancellationToken cancellationToken)
Task<IManagementResult<ListingPage<ItemWithVariantFilterResultModel>>> ListItemsWithVariantsByFilterPageAsync(ItemWithVariantFilterRequestModel filterRequest, String? continuationToken, CancellationToken cancellationToken)
Task<IManagementResult<ListingPage<LanguageVariantModel<T>>>> ListLanguageVariantsByTypePageAsync<T>(Reference identifier, String? continuationToken, CancellationToken cancellationToken)
Task<IManagementResult<ListingPage<LanguageVariantModel<T>>>> ListLanguageVariantsOfContentTypeWithComponentsPageAsync<T>(Reference identifier, String? continuationToken, CancellationToken cancellationToken)
Task<IManagementResult<ListingPage<LanguageVariantModel>>> ListLanguageVariantsByCollectionPageAsync(Reference identifier, String? continuationToken, CancellationToken cancellationToken)
Task<IManagementResult<ListingPage<LanguageVariantModel>>> ListLanguageVariantsBySpacePageAsync(Reference identifier, String? continuationToken, CancellationToken cancellationToken)
Task<IManagementResult<ListingPage<LanguageVariantModel>>> ListLanguageVariantsByTypePageAsync(Reference identifier, String? continuationToken, CancellationToken cancellationToken)
Expand Down Expand Up @@ -195,6 +200,9 @@ public sealed class ManagementClient : IAsyncDisposable, IDisposable, IManagemen
Task<IManagementResult<IReadOnlyList<EnvironmentRoleModel>>> ListEnvironmentRolesAsync(CancellationToken cancellationToken)
Task<IManagementResult<IReadOnlyList<ItemWithVariantFilterResultModel>>> ListItemsWithVariantsByFilterAsync(ItemWithVariantFilterRequestModel filterRequest, CancellationToken cancellationToken)
Task<IManagementResult<IReadOnlyList<LanguageModel>>> ListLanguagesAsync(CancellationToken cancellationToken)
Task<IManagementResult<IReadOnlyList<LanguageVariantModel<T>>>> ListLanguageVariantsByItemAsync<T>(Reference identifier, CancellationToken cancellationToken)
Task<IManagementResult<IReadOnlyList<LanguageVariantModel<T>>>> ListLanguageVariantsByTypeAsync<T>(Reference identifier, CancellationToken cancellationToken)
Task<IManagementResult<IReadOnlyList<LanguageVariantModel<T>>>> ListLanguageVariantsOfContentTypeWithComponentsAsync<T>(Reference identifier, CancellationToken cancellationToken)
Task<IManagementResult<IReadOnlyList<LanguageVariantModel>>> ListLanguageVariantsByCollectionAsync(Reference identifier, CancellationToken cancellationToken)
Task<IManagementResult<IReadOnlyList<LanguageVariantModel>>> ListLanguageVariantsByItemAsync(Reference identifier, CancellationToken cancellationToken)
Task<IManagementResult<IReadOnlyList<LanguageVariantModel>>> ListLanguageVariantsBySpaceAsync(Reference identifier, CancellationToken cancellationToken)
Expand All @@ -220,6 +228,8 @@ public sealed class ManagementClient : IAsyncDisposable, IDisposable, IManagemen
Task<IManagementResult<ListingPage<ContentItemModel>>> ListContentItemsPageAsync(String? continuationToken, CancellationToken cancellationToken)
Task<IManagementResult<ListingPage<ContentItemWithVariantModel>>> ListItemsWithVariantsByBulkGetPageAsync(ItemWithVariantBulkGetRequestModel bulkGetRequest, String? continuationToken, CancellationToken cancellationToken)
Task<IManagementResult<ListingPage<ItemWithVariantFilterResultModel>>> ListItemsWithVariantsByFilterPageAsync(ItemWithVariantFilterRequestModel filterRequest, String? continuationToken, CancellationToken cancellationToken)
Task<IManagementResult<ListingPage<LanguageVariantModel<T>>>> ListLanguageVariantsByTypePageAsync<T>(Reference identifier, String? continuationToken, CancellationToken cancellationToken)
Task<IManagementResult<ListingPage<LanguageVariantModel<T>>>> ListLanguageVariantsOfContentTypeWithComponentsPageAsync<T>(Reference identifier, String? continuationToken, CancellationToken cancellationToken)
Task<IManagementResult<ListingPage<LanguageVariantModel>>> ListLanguageVariantsByCollectionPageAsync(Reference identifier, String? continuationToken, CancellationToken cancellationToken)
Task<IManagementResult<ListingPage<LanguageVariantModel>>> ListLanguageVariantsBySpacePageAsync(Reference identifier, String? continuationToken, CancellationToken cancellationToken)
Task<IManagementResult<ListingPage<LanguageVariantModel>>> ListLanguageVariantsByTypePageAsync(Reference identifier, String? continuationToken, CancellationToken cancellationToken)
Expand Down Expand Up @@ -377,6 +387,7 @@ public static class ElementMetadataExtensions

// Kontent.Ai.Management.Extensions
public static class ManagementClientExtensions
static LanguageVariantModel<T> ToTyped<T>(IManagementClient client, LanguageVariantModel variant)
static Task<IManagementResult<AssetModel>> CreateAssetAsync(IManagementClient client, FileContentSource fileContent, Func<FileReference, AssetCreateModel> createModel, CancellationToken cancellationToken)
static Task<IManagementResult<AssetModel>> UpsertAssetAsync(IManagementClient client, Reference identifier, FileContentSource fileContent, AssetUpsertModel upsertModel, CancellationToken cancellationToken)
static Task<IManagementResult<ContentItemModel>> UpsertContentItemAsync(IManagementClient client, Reference identifier, ContentItemModel contentItem, CancellationToken cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ public async Task UpsertStronglyTypedLanguageVariant()
// Remove next line in codesample
var (client, mock) = MockClientFactory.Create(ArticleConverter());
// Remove next line in codesample
mock.Fallback.Respond("application/json", File.ReadAllText(Path.Combine(Environment.CurrentDirectory, "Data", SampleFolder, "ArticleLanguageVariantUpdatedResponse.json")));
mock.Fallback.Respond("application/json", File.ReadAllText(Path.Combine(Environment.CurrentDirectory, "Data", SampleFolder, "StronglyTypedArticleLanguageVariantUpdatedResponse.json")));

var identifier = LanguageVariantIdentifier.ByCodenames("on_roasts", "en-US");

Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,18 @@
using Kontent.Ai.Management.Configuration;
using Kontent.Ai.Management.Conversion;
using System.Text;
using System.Text.Json;

namespace Kontent.Ai.Management.Tests.Conversion;

// String-based conveniences over the converter's writer/element primitives — fixture-driven tests read better
// with JSON strings, but production only ever needs the stream/element paths.
// String-based conveniences over the converter's element/reader primitives — fixture-driven tests read better with
// JSON strings, but production only ever needs the element paths. WriteEnvelopes serializes the elements the way the
// upsert request does, so what the tests parse is what goes on the wire.
internal static class EnvelopeConverterTestExtensions
{
private static readonly JsonSerializerOptions WireOptions = RefitSettingsProvider.CreateDefaultJsonSerializerOptions();

public static string WriteEnvelopes<T>(this ContentItemEnvelopeConverter converter, T item) where T : IElementsModel
{
using var stream = new MemoryStream();
using (var writer = new Utf8JsonWriter(stream))
{
converter.WriteEnvelopes(writer, item);
}
return Encoding.UTF8.GetString(stream.ToArray());
}
=> JsonSerializer.Serialize(converter.ToElements(item), WireOptions);

public static T ReadEnvelopes<T>(this ContentItemEnvelopeConverter converter, string envelopesJson) where T : IElementsModel
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,4 +209,39 @@ public void Read_ValueKindMismatch_ThrowsWithElementContext()
.WithMessage("Failed to read element 'title' on 'Article'.")
.WithInnerException<InvalidOperationException>();
}

[Fact]
public void NoElementMatches_Throws()
{
// Every element unknown is the wrong record, not a forward-compat gap: an empty record would read as a
// variant with nothing set.
var json = """
[
{ "element": { "id": "35a9faae-e502-4e26-a824-26b90b9b2ecd" }, "value": "x" },
{ "element": { "id": "abe785d6-9146-4cab-8096-cba555d3840f" }, "value": null }
]
""";

var act = () => Converter.ReadEnvelopes<Article>(json);

act.Should().Throw<InvalidOperationException>()
.WithMessage("None of the 2 elements*")
.WithMessage("*'Article'*");
}

[Fact]
public void SingleChoice_ReadsTheSelectedOption()
{
var json = """[{"element":{"id":"d2e3f4a5-b6c7-5d8e-9f0a-b1c2d3e4f5a6"},"value":[{"codename":"dark"}]}]""";

Converter.ReadEnvelopes<Banner>(json).Tone.Should().Be(BannerTone.Dark);
}

[Fact]
public void SingleChoice_NothingSelected_ReadsNull()
{
var json = """[{"element":{"id":"d2e3f4a5-b6c7-5d8e-9f0a-b1c2d3e4f5a6"},"value":[]}]""";

Converter.ReadEnvelopes<Banner>(json).Tone.Should().BeNull();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,18 @@ public void RichText_WithoutComponents_WritesValueOnly()
envelopes["bio"].TryGetProperty("components", out _).Should().BeFalse();
}

[Fact]
public void SingleChoice_WritesOneElementArray()
{
var banner = new Banner { Tone = BannerTone.Dark };

var envelopes = ParseEnvelopes(Converter.WriteEnvelopes(banner));

var values = envelopes["tone"].GetProperty("value");
values.GetArrayLength().Should().Be(1);
values[0].GetProperty("codename").GetString().Should().Be("dark");
}

[Fact]
public void EnvelopeShape_KeyedByCodename()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"elements": [
{
"element": {
"id": "ae9be828-90fe-5fb4-ae3a-8dfe047e2567"
},
"value": "On Roasts - changed"
},
{
"element": {
"id": "dba8efd9-052c-557a-9dfd-3f43f966eab8"
},
"value": "2018-07-04T00:00:00Z",
"display_timezone": "Europe/Prague"
}
],
"item": {
"id": "9539c671-d578-4fd3-aa5c-b2d8e486c9b8"
},
"language": {
"id": "00000000-0000-0000-0000-000000000000"
},
"last_modified": "2021-11-10T11:35: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": []
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// <auto-generated>
// This code was generated by Kontent.ai model generator tool
// (see https://github.com/kontent-ai/model-generator-net).
//
// Changes to this file may cause incorrect behavior and will be lost if the code is regenerated.
// To extend this record, create a separate partial record with the same name.
// </auto-generated>

#nullable enable

using System;
using System.Collections.Generic;
using Kontent.Ai.Management;
using Kontent.Ai.Management.Annotations;
using Kontent.Ai.Management.Models.Content;

namespace MyProject.Models;

[KontentType("banner", "b7a6f1c2-3d4e-5f60-8a71-b2c3d4e5f608")]
public sealed partial record Banner : IElementsModel
{
[KontentElement("headline", "c1d2e3f4-a5b6-5c7d-8e9f-a0b1c2d3e4f5")]
public string? Headline { get; init; }
[KontentElement("tone", "d2e3f4a5-b6c7-5d8e-9f0a-b1c2d3e4f5a6")]
public BannerTone? Tone { get; init; }
}

public enum BannerTone
{
[KontentEnumValue("light", "e3f4a5b6-c7d8-5e9f-0a1b-c2d3e4f5a6b7")]
Light,
[KontentEnumValue("dark", "f4a5b6c7-d8e9-5f0a-1b2c-d3e4f5a6b7c8")]
Dark
}
Loading