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 @@ -12,6 +12,10 @@
<InternalsVisibleTo Include="HotChocolate.Adapters.OpenApi.AspNetCore" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="System.Reactive" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\..\AspNetCore\src\AspNetCore.Pipeline\HotChocolate.AspNetCore.Pipeline.csproj" />
<ProjectReference Include="..\..\..\Core\src\Execution.Abstractions\HotChocolate.Execution.Abstractions.csproj" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
#if !NET9_0_OR_GREATER
using System.Diagnostics.CodeAnalysis;
#endif
using System.Reactive.Linq;
using HotChocolate.Utilities;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;

namespace HotChocolate.Adapters.OpenApi;

#if !NET9_0_OR_GREATER
[RequiresDynamicCode("JSON serialization and deserialization might require types that cannot be statically analyzed and might need runtime code generation. Use System.Text.Json source generation for native AOT applications.")]
[RequiresUnreferencedCode("JSON serialization and deserialization might require types that cannot be statically analyzed. Use the overload that takes a JsonTypeInfo or JsonSerializerContext, or make sure all of the required types are preserved.")]
[RequiresDynamicCode(
"JSON serialization and deserialization might require types that cannot be statically analyzed and might need runtime code generation. Use System.Text.Json source generation for native AOT applications.")]
[RequiresUnreferencedCode(
"JSON serialization and deserialization might require types that cannot be statically analyzed. Use the overload that takes a JsonTypeInfo or JsonSerializerContext, or make sure all of the required types are preserved.")]
#endif
internal sealed class OpenApiDefinitionRegistry : IAsyncDisposable
{
Expand All @@ -20,6 +20,7 @@ internal sealed class OpenApiDefinitionRegistry : IAsyncDisposable
private readonly IDynamicEndpointDataSource _dynamicEndpointDataSource;
private readonly SemaphoreSlim _updateSemaphore = new(1, 1);
private readonly CancellationTokenSource _cancellationTokenSource = new();
private readonly IDisposable _subscription;

private ISchemaDefinition? _schema;
private bool _disposed;
Expand All @@ -33,7 +34,10 @@ public OpenApiDefinitionRegistry(
_transformer = transformer;
_dynamicEndpointDataSource = dynamicEndpointDataSource;

_storage.Changed += OnStorageChanged;
_subscription = _storage
.Buffer(TimeSpan.FromMilliseconds(500), 10)
.Where(batch => batch.Count > 0)
.Subscribe(_ => HandleStorageChangedAsync().FireAndForget());
}

public async ValueTask UpdateSchemaAsync(
Expand Down Expand Up @@ -63,38 +67,43 @@ public async ValueTask DisposeAsync()
{
_disposed = true;

_storage.Changed -= OnStorageChanged;
_updateSemaphore.Dispose();
_subscription.Dispose();

// Cancel before disposing the semaphore so any in-flight WaitAsync(token)
// observes the cancellation and exits, instead of being orphaned forever
// in the semaphore's waiter list (Dispose does not complete pending waits).
await _cancellationTokenSource.CancelAsync().ConfigureAwait(false);

_updateSemaphore.Dispose();
_cancellationTokenSource.Dispose();
}
}

private void OnStorageChanged(object? sender, EventArgs e)
private async Task HandleStorageChangedAsync()
{
if (_schema is null)
if (_disposed || _schema is null)
{
return;
}

HandleStorageChangedAsync().FireAndForget();
}
var cancellationToken = _cancellationTokenSource.Token;

private async Task HandleStorageChangedAsync()
{
if (_schema is null)
try
{
await _updateSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
catch (ObjectDisposedException)
{
return;
}

var cancellationToken = _cancellationTokenSource.Token;

await _updateSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);

try
{
if (_schema is null)
if (_disposed || _schema is null)
{
return;
}
Expand All @@ -110,7 +119,13 @@ private async Task HandleStorageChangedAsync()
}
finally
{
_updateSemaphore.Release();
try
{
_updateSemaphore.Release();
}
catch (ObjectDisposedException)
{
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
using HotChocolate.Adapters.OpenApi.Storage;

namespace HotChocolate.Adapters.OpenApi;

/// <summary>
/// Provides access to OpenAPI definitions with change notification support.
/// Implementations can retrieve definitions from various sources (file system, database, etc.).
/// The Hot Chocolate OpenAPI adapter will observe the <see cref="IOpenApiDefinitionStorage"/>
/// and when changes are detected will phase in new definitions, update definitions, or phase out
/// definitions that have been removed from the storage.
/// </summary>
public interface IOpenApiDefinitionStorage
: IObservable<OpenApiDefinitionStorageEventArgs>
{
/// <summary>
/// Gets all definitions from the storage.
/// </summary>
ValueTask<IEnumerable<IOpenApiDefinition>> GetDefinitionsAsync(CancellationToken cancellationToken = default);

/// <summary>
/// Event that is raised when the storage contents have changed.
/// </summary>
event EventHandler? Changed;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace HotChocolate.Adapters.OpenApi.Storage;

/// <summary>
/// Event arguments for OpenAPI definition storage changes.
/// </summary>
/// <param name="Name">The name (id) of the definition that changed.</param>
/// <param name="Type">The type of change that occurred.</param>
/// <param name="Definition">The definition. Required for Updated, null for Removed.</param>
public record OpenApiDefinitionStorageEventArgs(
string Name,
OpenApiDefinitionStorageEventType Type,
IOpenApiDefinition? Definition = null);
Comment thread
glen-84 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace HotChocolate.Adapters.OpenApi.Storage;

/// <summary>
/// Defines the types of changes that can occur in OpenAPI definition storage.
/// </summary>
public enum OpenApiDefinitionStorageEventType
{
/// <summary>A definition was added to or updated in storage.</summary>
Updated,

/// <summary>A definition was removed from storage.</summary>
Removed
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System.Collections.Immutable;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using HotChocolate.Adapters.OpenApi.Storage;
using HotChocolate.Language;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.OpenApi;
Expand Down Expand Up @@ -313,16 +315,16 @@ public static string GenerateToken(string? role = null)
}
}

protected sealed class TestOpenApiDefinitionStorage : IOpenApiDefinitionStorage
protected sealed class TestOpenApiDefinitionStorage : IOpenApiDefinitionStorage, IDisposable
{
#if NET9_0_OR_GREATER
private readonly Lock _lock = new();
#else
private readonly object _lock = new();
#endif
private readonly Dictionary<string, IOpenApiDefinition> _definitionsById = [];

public event EventHandler? Changed;
private ImmutableList<ObserverSession> _sessions = [];
private bool _disposed;

public TestOpenApiDefinitionStorage(params IEnumerable<string>? documents)
{
Expand Down Expand Up @@ -361,26 +363,112 @@ public void AddOrUpdateDefinition(string id, IOpenApiDefinition definition)
lock (_lock)
{
_definitionsById[id] = definition;
OnChanged();
}

Notify(id, definition, OpenApiDefinitionStorageEventType.Updated);
}

public void RemoveDocument(string id)
{
bool removed;

lock (_lock)
{
var removed = _definitionsById.Remove(id);
removed = _definitionsById.Remove(id);
}

if (removed)
{
Notify(id, definition: null, OpenApiDefinitionStorageEventType.Removed);
}
}

public IDisposable Subscribe(IObserver<OpenApiDefinitionStorageEventArgs> observer)
{
return new ObserverSession(this, observer);
}

private void Notify(string name, IOpenApiDefinition? definition, OpenApiDefinitionStorageEventType type)
{
if (type is OpenApiDefinitionStorageEventType.Updated)
{
ArgumentNullException.ThrowIfNull(definition);
}

if (_disposed)
{
return;
}

var sessions = _sessions;
var eventArgs = new OpenApiDefinitionStorageEventArgs(name, type, definition);

foreach (var session in sessions)
{
session.Notify(eventArgs);
}
}

public void Dispose()
{
if (_disposed)
{
return;
}

if (removed)
lock (_lock)
{
foreach (var session in _sessions)
{
OnChanged();
session.Dispose();
}

_sessions = [];
_disposed = true;
}
Comment thread
glen-84 marked this conversation as resolved.
}

private void OnChanged()
private sealed class ObserverSession : IDisposable
{
Changed?.Invoke(this, EventArgs.Empty);
private readonly TestOpenApiDefinitionStorage _storage;
private readonly IObserver<OpenApiDefinitionStorageEventArgs> _observer;
private bool _disposed;

public ObserverSession(
TestOpenApiDefinitionStorage storage,
IObserver<OpenApiDefinitionStorageEventArgs> observer)
{
_storage = storage;
_observer = observer;

lock (storage._lock)
{
_storage._sessions = _storage._sessions.Add(this);
}
}

public void Notify(OpenApiDefinitionStorageEventArgs eventArgs)
{
if (!_disposed && !_storage._disposed)
{
_observer.OnNext(eventArgs);
}
}

public void Dispose()
{
if (_disposed)
{
return;
}

lock (_storage._lock)
{
_storage._sessions = _storage._sessions.Remove(this);
}

_disposed = true;
}
}
}
}
11 changes: 8 additions & 3 deletions website/src/docs/hotchocolate/v16/guides/openapi-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,12 +203,11 @@ The `IOpenApiDefinitionStorage` interface provides endpoint and fragment definit
```csharp
// Services/MyOpenApiStorage.cs
using HotChocolate.Adapters.OpenApi;
using HotChocolate.Adapters.OpenApi.Storage;
using HotChocolate.Language;

public class MyOpenApiStorage : IOpenApiDefinitionStorage
{
public event EventHandler? Changed;

public ValueTask<IEnumerable<IOpenApiDefinition>>
GetDefinitionsAsync(
CancellationToken cancellationToken = default)
Expand All @@ -230,6 +229,12 @@ public class MyOpenApiStorage : IOpenApiDefinitionStorage
return ValueTask.FromResult<IEnumerable<IOpenApiDefinition>>(
documents);
}

// IOpenApiDefinitionStorage also extends IObservable, enabling
// hot-reload when definitions change.
public IDisposable Subscribe(
IObserver<OpenApiDefinitionStorageEventArgs> observer)
=> /* your subscription logic */;
}
```

Expand All @@ -245,7 +250,7 @@ builder
.AddOpenApiDefinitionStorage(storage);
```

The storage raises its `Changed` event when definitions are modified. The adapter picks up changes at runtime, adding, updating, or removing HTTP endpoints without a restart. This hot-reload behavior extends to the OpenAPI specification.
The storage implements `IObservable<OpenApiDefinitionStorageEventArgs>`. When you push `Updated` or `Removed` events through this observable, the adapter picks up changes at runtime, adding, updating, or removing HTTP endpoints without a restart. This hot-reload behavior extends to the OpenAPI specification.

# OpenAPI Specification

Expand Down
Loading