Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public override AIProviderConnection Read(ref Utf8JsonReader reader, Type typeTo
?? GetString(node, "ProviderName"),
Name = GetString(node, nameof(AIProviderConnection.Name)),
DisplayText = GetString(node, nameof(AIProviderConnection.DisplayText)),
IsReadOnly = GetBoolean(node, nameof(AIProviderConnection.IsReadOnly)),
CreatedUtc = GetDateTime(node, nameof(AIProviderConnection.CreatedUtc)),
Author = GetString(node, nameof(AIProviderConnection.Author)),
OwnerId = GetString(node, nameof(AIProviderConnection.OwnerId)),
Expand Down Expand Up @@ -58,6 +59,7 @@ public override void Write(Utf8JsonWriter writer, AIProviderConnection value, Js
WriteString(writer, nameof(AIProviderConnection.ClientName), value.ClientName);
WriteString(writer, nameof(AIProviderConnection.Name), value.Name);
WriteString(writer, nameof(AIProviderConnection.DisplayText), value.DisplayText);
writer.WriteBoolean(nameof(AIProviderConnection.IsReadOnly), value.IsReadOnly);
writer.WriteString(nameof(AIProviderConnection.CreatedUtc), value.CreatedUtc);
WriteString(writer, nameof(AIProviderConnection.Author), value.Author);
WriteString(writer, nameof(AIProviderConnection.OwnerId), value.OwnerId);
Expand Down Expand Up @@ -87,6 +89,16 @@ private static string GetString(JsonObject node, string name)
return null;
}

private static bool GetBoolean(JsonObject node, string name)
{
if (node.TryGetPropertyValue(name, out var value) && value != null)
{
return value.GetValue<bool>();
}

return false;
}

private static DateTime GetDateTime(JsonObject node, string name)
{
if (node.TryGetPropertyValue(name, out var value) && value != null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ public string ModelName

public string OwnerId { get; set; }

/// <summary>
/// Gets or sets whether this catalog item is read-only.
/// Configuration-backed entries are read-only and cannot be modified or deleted through the UI.
/// </summary>
public bool IsReadOnly { get; set; }

public bool SupportsType(AIDeploymentType type)
{
return Type.Supports(type);
Expand All @@ -74,6 +80,7 @@ public AIDeployment Clone()
Source = Source,
ConnectionName = ConnectionName,
Type = Type,
IsReadOnly = IsReadOnly,
CreatedUtc = CreatedUtc,
Author = Author,
OwnerId = OwnerId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ public string ProviderName

public string OwnerId { get; set; }

/// <summary>
/// Gets or sets whether this catalog item is read-only.
/// Configuration-backed entries are read-only and cannot be modified or deleted through the UI.
/// </summary>
public bool IsReadOnly { get; set; }

public AIProviderConnection Clone()
{
return new AIProviderConnection
Expand All @@ -45,6 +51,7 @@ public AIProviderConnection Clone()
Source = Source,
Name = Name,
DisplayText = DisplayText,
IsReadOnly = IsReadOnly,
CreatedUtc = CreatedUtc,
Author = Author,
OwnerId = OwnerId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,33 @@ namespace CrestApps.Core;
/// </summary>
public static class ExtensibleEntityExtensions
{
private static readonly JsonSerializerOptions _jsonOptions = new()
private static JsonSerializerOptions _jsonOptions = ExtensibleEntityJsonOptions.CreateDefaultSerializerOptions();

/// <summary>
/// Gets or sets the <see cref="JsonSerializerOptions"/> used for serializing and
/// deserializing extensible entity properties.
/// </summary>
/// <remarks>
/// This property is initialized with sensible defaults. To customize, either:
/// <list type="bullet">
/// <item>Set this property directly at application startup before any serialization occurs.</item>
/// <item>Use the DI options pattern with <see cref="ExtensibleEntityJsonOptions"/> (requires CrestApps.Core).</item>
/// </list>
/// </remarks>
public static JsonSerializerOptions JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
};
get => _jsonOptions;
set
{
ArgumentNullException.ThrowIfNull(value);
_jsonOptions = value;
}
}

/// <summary>
/// Gets a strongly-typed object stored in the entity's properties.
/// </summary>
public static T As<T>(this ExtensibleEntity entity)
public static T GetOrCreate<T>(this ExtensibleEntity entity)
where T : new()
{
ArgumentNullException.ThrowIfNull(entity);
Expand Down Expand Up @@ -73,7 +91,7 @@ public static ExtensibleEntity Put(this ExtensibleEntity entity, string name, ob
/// Returns <c>true</c> if a non-null value was found and deserialized.
/// </summary>
public static bool TryGet<T>(this ExtensibleEntity entity, out T result)
where T : class, new()
where T : class
{
ArgumentNullException.ThrowIfNull(entity);

Expand Down Expand Up @@ -109,7 +127,7 @@ public static ExtensibleEntity Alter<T>(this ExtensibleEntity entity, Action<T>
ArgumentNullException.ThrowIfNull(entity);
ArgumentNullException.ThrowIfNull(alter);

var obj = entity.As<T>();
var obj = entity.GetOrCreate<T>();
alter(obj);
entity.Put(obj);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using System.Text.Json;
using System.Text.Json.Serialization;

namespace CrestApps.Core;

/// <summary>
/// Options for configuring the <see cref="JsonSerializerOptions"/> used by
/// <see cref="ExtensibleEntityExtensions"/> when serializing and deserializing
/// extensible entity properties.
/// </summary>
/// <remarks>
/// Register this class with the DI options pattern to customize serialization behavior:
/// <code>
/// services.Configure&lt;ExtensibleEntityJsonOptions&gt;(options =&gt;
/// {
/// options.SerializerOptions.Converters.Add(new MyCustomConverter());
/// });
/// </code>
/// </remarks>
public sealed class ExtensibleEntityJsonOptions
{
/// <summary>
/// Gets or sets the <see cref="JsonSerializerOptions"/> used for serializing and
/// deserializing extensible entity properties.
/// </summary>
public JsonSerializerOptions SerializerOptions { get; set; } = CreateDefaultSerializerOptions();

/// <summary>
/// Creates a new <see cref="JsonSerializerOptions"/> instance with the default settings
/// used by <see cref="ExtensibleEntityExtensions"/>.
/// </summary>
public static JsonSerializerOptions CreateDefaultSerializerOptions() => new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PreferredObjectCreationHandling = JsonObjectCreationHandling.Populate,
ReferenceHandler = null,
ReadCommentHandling = JsonCommentHandling.Skip,
PropertyNameCaseInsensitive = true,
AllowTrailingCommas = true,
WriteIndented = false,
NumberHandling = JsonNumberHandling.AllowReadingFromString,
Converters =
{
new JsonStringEnumConverter(),
},
};
}
132 changes: 132 additions & 0 deletions src/CrestApps.Core.Docs/docs/core/extensible-entity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
---
sidebar_label: ExtensibleEntity
sidebar_position: 3
title: ExtensibleEntity & JSON Configuration
description: Learn how to use ExtensibleEntity for dynamic property storage and how to configure JSON serialization with ExtensibleEntityJsonOptions.
---

# ExtensibleEntity & JSON Configuration

> Base class for entities that support dynamic, schema-free properties alongside their typed fields.

## Overview

`ExtensibleEntity` provides a `Properties` dictionary that allows you to attach arbitrary strongly-typed data to any entity without changing its schema. This powers features like AI profile metadata, chat session annotations, and custom application data.

## Quick Reference

| Method | When to Use |
|--------|-------------|
| `TryGet<T>(out T result)` | **Read-only** access — returns `false` when the key is missing (zero allocations) |
| `GetOrCreate<T>()` | **Read-write** access — creates a new `T` when the key is missing |
| `Alter<T>(Action<T>)` | Modify a stored object in-place (creates if missing) |
| `Put<T>(T value)` | Store a strongly-typed object (key = type name) |
| `Put(string name, object value)` | Store a value under a custom key |
| `Has<T>()` | Check whether a key exists |
| `Remove<T>()` | Remove a stored object |

### Prefer `TryGet` for Read-Only Access

When you only need to **read** a property and do not need a default instance, use `TryGet<T>`:

```csharp
if (profile.TryGet<MyMetadata>(out var metadata))
{
// Use metadata — only entered when data is present.
Console.WriteLine(metadata.Label);
}
```

`GetOrCreate<T>()` allocates a new `T` every time the key is absent. Reserve it for cases where you intend to write back:

```csharp
var metadata = profile.GetOrCreate<MyMetadata>();
metadata.Label = "Updated";
profile.Put(metadata);
```

## Configuring JSON Serialization

All `ExtensibleEntity` property reads and writes go through a shared `JsonSerializerOptions` instance. The defaults work for most scenarios, but you can customize them.

### Default Settings

| Setting | Default Value |
|---------|---------------|
| `DefaultIgnoreCondition` | `WhenWritingNull` |
| `PreferredObjectCreationHandling` | `Populate` |
| `PropertyNameCaseInsensitive` | `true` |
| `ReadCommentHandling` | `Skip` |
| `AllowTrailingCommas` | `true` |
| `WriteIndented` | `false` |
| `NumberHandling` | `AllowReadingFromString` |
| Built-in converters | `JsonStringEnumConverter` |

### Using the Options Pattern (Recommended)

When you use the CrestApps framework with `AddCrestAppsCore()`, register your customizations through the standard options pattern:

```csharp
builder.Services.AddCrestAppsCore(crestApps => crestApps
.AddAISuite(ai => ai
.AddOpenAI()
)
);

// Add a custom JSON converter for ExtensibleEntity properties.
builder.Services.Configure<ExtensibleEntityJsonOptions>(options =>
{
options.SerializerOptions.Converters.Add(new MyCustomJsonConverter());
});
```

The framework registers an `IHostedService` that reads `IOptions<ExtensibleEntityJsonOptions>` at startup and pushes the configured `JsonSerializerOptions` to `ExtensibleEntityExtensions.JsonSerializerOptions`. Your customizations are applied before any request processing begins.

### Direct Static Property (Advanced)

If you are not using the CrestApps DI extensions (e.g., in a console app or test harness), set the static property directly **before any serialization occurs**:

```csharp
var options = ExtensibleEntityJsonOptions.CreateDefaultSerializerOptions();
options.Converters.Add(new MyCustomJsonConverter());

ExtensibleEntityExtensions.JsonSerializerOptions = options;
```

:::warning
`JsonSerializerOptions` becomes immutable after the first serialization call (System.Text.Json caches internal metadata). Configure it during application startup, before any `ExtensibleEntity` methods are invoked.
:::

## Full Example

```csharp
using CrestApps.Core;

// Define a custom metadata type.
public sealed class InvoiceMetadata
{
public string InvoiceNumber { get; set; }
public decimal Amount { get; set; }
public bool IsPaid { get; set; }
}

// Store metadata on an entity.
entity.Put(new InvoiceMetadata
{
InvoiceNumber = "INV-2025-001",
Amount = 149.99m,
IsPaid = false,
});

// Read-only check — no allocation if missing.
if (entity.TryGet<InvoiceMetadata>(out var invoice))
{
Console.WriteLine($"Invoice {invoice.InvoiceNumber}: ${invoice.Amount}");
}

// Modify in-place.
entity.Alter<InvoiceMetadata>(inv =>
{
inv.IsPaid = true;
});
```
1 change: 1 addition & 0 deletions src/CrestApps.Core.Docs/sidebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const sidebars = {
items: [
'core/architecture',
'core/core-services',
'core/extensible-entity',
'core/getting-started-aspnet',
'core/index',
'core/interfaces',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,10 @@ protected override async ValueTask<object> InvokeCoreAsync(
var connectionStore = arguments.Services.GetRequiredService<ICatalog<A2AConnection>>();

var connection = await connectionStore.FindByIdAsync(_connectionId);

if (connection is not null)
if (connection is not null && connection.TryGet<A2AConnectionMetadata>(out var metadata))
{
var metadata = connection.As<A2AConnectionMetadata>();
await authService.ConfigureHttpClientAsync(httpClient, metadata, cancellationToken);
}

}

var client = new A2AClient(new Uri(_endpoint), httpClient);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,16 @@ public async Task<AgentCard> GetAgentCardAsync(string connectionId, A2AConnectio
try
{
var httpClient = _httpClientFactory.CreateClient();
var metadata = connection.As<A2AConnectionMetadata>();
// Resolve the scoped auth service from the current request to avoid
// capturing a scoped service in this singleton.
var authService = _httpContextAccessor.HttpContext?.RequestServices.GetService<IA2AConnectionAuthService>();
if (authService is not null)

if (connection.TryGet<A2AConnectionMetadata>(out var metadata))
{
await authService.ConfigureHttpClientAsync(httpClient, metadata, cancellationToken);
// Resolve the scoped auth service from the current request to avoid
// capturing a scoped service in this singleton.
var authService = _httpContextAccessor.HttpContext?.RequestServices.GetService<IA2AConnectionAuthService>();
if (authService is not null)
{
await authService.ConfigureHttpClientAsync(httpClient, metadata, cancellationToken);
}
}

var resolver = new A2ACardResolver(new Uri(connection.Endpoint), httpClient);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,7 @@ private static IReadOnlyList<IFormFile> GetFiles(IFormCollection form)

private static bool IsSessionDocumentUploadEnabled(AIProfile profile)
{
return profile.As<AIProfileSessionDocumentsMetadata>()?.AllowSessionDocuments == true;
return profile.TryGet<AIProfileSessionDocumentsMetadata>(out var sessionDocMetadata) && sessionDocMetadata.AllowSessionDocuments;
}

private static async Task<AIDeployment> ResolveSessionDeploymentAsync(AIProfile profile, IAIDeploymentManager deploymentManager)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@ public Task BuiltAsync(AICompletionContextBuiltContext context)

private async Task<string> ResolveSystemMessageAsync(ChatInteraction interaction)
{
var promptMetadata = interaction.As<PromptTemplateMetadata>();
if (!interaction.TryGet<PromptTemplateMetadata>(out var promptMetadata))
{
return interaction.SystemMessage;
}

var validTemplates = promptMetadata.Templates?.Where(selection => !string.IsNullOrWhiteSpace(selection.TemplateId)).ToList();
if (validTemplates is not { Count: > 0 })
{
Expand Down
Loading
Loading