From 54f822b0cb53f457fec575e3bd84815961ec17a0 Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Fri, 12 Apr 2024 15:50:01 -0700 Subject: [PATCH 01/45] Add async and sync enumerable client results --- .../api/System.ClientModel.net6.0.cs | 15 ++++++++++ .../api/System.ClientModel.netstandard2.0.cs | 15 ++++++++++ .../Convenience/AsyncEnumerableResultOfT.cs | 28 +++++++++++++++++ .../Convenience/EnumerableClientResultOfT.cs | 30 +++++++++++++++++++ 4 files changed, 88 insertions(+) create mode 100644 sdk/core/System.ClientModel/src/Convenience/AsyncEnumerableResultOfT.cs create mode 100644 sdk/core/System.ClientModel/src/Convenience/EnumerableClientResultOfT.cs diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs index 3edacd35c1d41..f67871e155fb1 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs @@ -7,6 +7,13 @@ public ApiKeyCredential(string key) { } public static implicit operator System.ClientModel.ApiKeyCredential (string key) { throw null; } public void Update(string key) { } } + public abstract partial class AsyncEnumerableResult : System.ClientModel.ClientResult, System.Collections.Generic.IAsyncEnumerable, System.IDisposable where T : System.ClientModel.Primitives.IPersistableModel + { + protected internal AsyncEnumerableResult(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } + public void Dispose() { } + protected abstract void Dispose(bool disposing); + public abstract System.Collections.Generic.IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + } public abstract partial class BinaryContent : System.IDisposable { protected BinaryContent() { } @@ -40,6 +47,14 @@ protected internal ClientResult(T value, System.ClientModel.Primitives.PipelineR public virtual T Value { get { throw null; } } public static implicit operator T (System.ClientModel.ClientResult result) { throw null; } } + public abstract partial class EnumerableClientResult : System.ClientModel.ClientResult, System.Collections.Generic.IEnumerable, System.Collections.IEnumerable, System.IDisposable where T : System.ClientModel.Primitives.IPersistableModel + { + protected internal EnumerableClientResult(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } + public void Dispose() { } + protected abstract void Dispose(bool disposing); + public abstract System.Collections.Generic.IEnumerator GetEnumerator(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { throw null; } + } } namespace System.ClientModel.Primitives { diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs index 2367ba7f518d1..fa1bebd8c1f20 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs @@ -7,6 +7,13 @@ public ApiKeyCredential(string key) { } public static implicit operator System.ClientModel.ApiKeyCredential (string key) { throw null; } public void Update(string key) { } } + public abstract partial class AsyncEnumerableResult : System.ClientModel.ClientResult, System.Collections.Generic.IAsyncEnumerable, System.IDisposable where T : System.ClientModel.Primitives.IPersistableModel + { + protected internal AsyncEnumerableResult(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } + public void Dispose() { } + protected abstract void Dispose(bool disposing); + public abstract System.Collections.Generic.IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + } public abstract partial class BinaryContent : System.IDisposable { protected BinaryContent() { } @@ -40,6 +47,14 @@ protected internal ClientResult(T value, System.ClientModel.Primitives.PipelineR public virtual T Value { get { throw null; } } public static implicit operator T (System.ClientModel.ClientResult result) { throw null; } } + public abstract partial class EnumerableClientResult : System.ClientModel.ClientResult, System.Collections.Generic.IEnumerable, System.Collections.IEnumerable, System.IDisposable where T : System.ClientModel.Primitives.IPersistableModel + { + protected internal EnumerableClientResult(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } + public void Dispose() { } + protected abstract void Dispose(bool disposing); + public abstract System.Collections.Generic.IEnumerator GetEnumerator(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { throw null; } + } } namespace System.ClientModel.Primitives { diff --git a/sdk/core/System.ClientModel/src/Convenience/AsyncEnumerableResultOfT.cs b/sdk/core/System.ClientModel/src/Convenience/AsyncEnumerableResultOfT.cs new file mode 100644 index 0000000000000..beec5f109ad6f --- /dev/null +++ b/sdk/core/System.ClientModel/src/Convenience/AsyncEnumerableResultOfT.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Threading; + +namespace System.ClientModel; + +#pragma warning disable CS1591 // public XML comments +public abstract class AsyncEnumerableResult : ClientResult, IAsyncEnumerable, IDisposable + where T : IPersistableModel +{ + protected internal AsyncEnumerableResult(PipelineResponse response) : base(response) + { + } + + public abstract IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default); + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + protected abstract void Dispose(bool disposing); +} +#pragma warning restore CS1591 // public XML comments diff --git a/sdk/core/System.ClientModel/src/Convenience/EnumerableClientResultOfT.cs b/sdk/core/System.ClientModel/src/Convenience/EnumerableClientResultOfT.cs new file mode 100644 index 0000000000000..e2b2565693f8d --- /dev/null +++ b/sdk/core/System.ClientModel/src/Convenience/EnumerableClientResultOfT.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ClientModel.Primitives; +using System.Collections; +using System.Collections.Generic; + +namespace System.ClientModel; + +#pragma warning disable CS1591 // public XML comments +public abstract class EnumerableClientResult : ClientResult, IEnumerable, IDisposable + where T : IPersistableModel +{ + protected internal EnumerableClientResult(PipelineResponse response) : base(response) + { + } + + public abstract IEnumerator GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + protected abstract void Dispose(bool disposing); +} +#pragma warning restore CS1591 // public XML comments From d239e170e7319ac568a2eff209f476071c87f863 Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Mon, 15 Apr 2024 10:03:47 -0700 Subject: [PATCH 02/45] Implement IAsyncDisposable --- .../api/System.ClientModel.net6.0.cs | 5 +++-- .../api/System.ClientModel.netstandard2.0.cs | 5 +++-- .../Convenience/AsyncEnumerableResultOfT.cs | 19 ++++++++++++++----- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs index f67871e155fb1..404892b31a34f 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs @@ -7,11 +7,12 @@ public ApiKeyCredential(string key) { } public static implicit operator System.ClientModel.ApiKeyCredential (string key) { throw null; } public void Update(string key) { } } - public abstract partial class AsyncEnumerableResult : System.ClientModel.ClientResult, System.Collections.Generic.IAsyncEnumerable, System.IDisposable where T : System.ClientModel.Primitives.IPersistableModel + public abstract partial class AsyncEnumerableResult : System.ClientModel.ClientResult, System.Collections.Generic.IAsyncEnumerable, System.IAsyncDisposable where T : System.ClientModel.Primitives.IPersistableModel { protected internal AsyncEnumerableResult(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } - public void Dispose() { } protected abstract void Dispose(bool disposing); + public System.Threading.Tasks.ValueTask DisposeAsync() { throw null; } + protected abstract System.Threading.Tasks.ValueTask DisposeAsyncCore(); public abstract System.Collections.Generic.IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); } public abstract partial class BinaryContent : System.IDisposable diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs index fa1bebd8c1f20..441723643ff78 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs @@ -7,11 +7,12 @@ public ApiKeyCredential(string key) { } public static implicit operator System.ClientModel.ApiKeyCredential (string key) { throw null; } public void Update(string key) { } } - public abstract partial class AsyncEnumerableResult : System.ClientModel.ClientResult, System.Collections.Generic.IAsyncEnumerable, System.IDisposable where T : System.ClientModel.Primitives.IPersistableModel + public abstract partial class AsyncEnumerableResult : System.ClientModel.ClientResult, System.Collections.Generic.IAsyncEnumerable, System.IAsyncDisposable where T : System.ClientModel.Primitives.IPersistableModel { protected internal AsyncEnumerableResult(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } - public void Dispose() { } protected abstract void Dispose(bool disposing); + public System.Threading.Tasks.ValueTask DisposeAsync() { throw null; } + protected abstract System.Threading.Tasks.ValueTask DisposeAsyncCore(); public abstract System.Collections.Generic.IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); } public abstract partial class BinaryContent : System.IDisposable diff --git a/sdk/core/System.ClientModel/src/Convenience/AsyncEnumerableResultOfT.cs b/sdk/core/System.ClientModel/src/Convenience/AsyncEnumerableResultOfT.cs index beec5f109ad6f..7a1a290194d76 100644 --- a/sdk/core/System.ClientModel/src/Convenience/AsyncEnumerableResultOfT.cs +++ b/sdk/core/System.ClientModel/src/Convenience/AsyncEnumerableResultOfT.cs @@ -4,11 +4,12 @@ using System.ClientModel.Primitives; using System.Collections.Generic; using System.Threading; +using System.Threading.Tasks; namespace System.ClientModel; #pragma warning disable CS1591 // public XML comments -public abstract class AsyncEnumerableResult : ClientResult, IAsyncEnumerable, IDisposable +public abstract class AsyncEnumerableResult : ClientResult, IAsyncEnumerable, IAsyncDisposable where T : IPersistableModel { protected internal AsyncEnumerableResult(PipelineResponse response) : base(response) @@ -17,12 +18,20 @@ protected internal AsyncEnumerableResult(PipelineResponse response) : base(respo public abstract IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default); - public void Dispose() + protected abstract ValueTask DisposeAsyncCore(); + + protected abstract void Dispose(bool disposing); + + public async ValueTask DisposeAsync() { - Dispose(disposing: true); + await DisposeAsyncCore().ConfigureAwait(false); + + // Dispose of unmanaged resources. Note that DisposeAsyncCore disposes + // of managed resources asychronously -- we pass false to Dispose so we + // don't attempt to dispose of them synchronously as well. + Dispose(disposing: false); + GC.SuppressFinalize(this); } - - protected abstract void Dispose(bool disposing); } #pragma warning restore CS1591 // public XML comments From 3b12f1f54b2a0244e582e6f0aec54d248d5a7bae Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Fri, 19 Apr 2024 16:30:45 -0700 Subject: [PATCH 03/45] Remove constrant and disposable; update ClientResult so response can be replaced in a polling paradigm --- .../api/System.ClientModel.net6.0.cs | 12 ++---- .../api/System.ClientModel.netstandard2.0.cs | 12 ++---- .../Convenience/AsyncEnumerableResultOfT.cs | 20 +--------- .../src/Convenience/ClientResult.cs | 39 ++++++++++++++++--- .../Convenience/EnumerableClientResultOfT.cs | 11 +----- 5 files changed, 43 insertions(+), 51 deletions(-) diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs index 404892b31a34f..01a5933c6ab35 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs @@ -7,12 +7,9 @@ public ApiKeyCredential(string key) { } public static implicit operator System.ClientModel.ApiKeyCredential (string key) { throw null; } public void Update(string key) { } } - public abstract partial class AsyncEnumerableResult : System.ClientModel.ClientResult, System.Collections.Generic.IAsyncEnumerable, System.IAsyncDisposable where T : System.ClientModel.Primitives.IPersistableModel + public abstract partial class AsyncEnumerableResult : System.ClientModel.ClientResult, System.Collections.Generic.IAsyncEnumerable { protected internal AsyncEnumerableResult(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } - protected abstract void Dispose(bool disposing); - public System.Threading.Tasks.ValueTask DisposeAsync() { throw null; } - protected abstract System.Threading.Tasks.ValueTask DisposeAsyncCore(); public abstract System.Collections.Generic.IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); } public abstract partial class BinaryContent : System.IDisposable @@ -28,11 +25,12 @@ protected BinaryContent() { } } public partial class ClientResult { - protected ClientResult(System.ClientModel.Primitives.PipelineResponse response) { } + protected ClientResult(System.ClientModel.Primitives.PipelineResponse? response) { } public static System.ClientModel.ClientResult FromOptionalValue(T? value, System.ClientModel.Primitives.PipelineResponse response) { throw null; } public static System.ClientModel.ClientResult FromResponse(System.ClientModel.Primitives.PipelineResponse response) { throw null; } public static System.ClientModel.ClientResult FromValue(T value, System.ClientModel.Primitives.PipelineResponse response) { throw null; } public System.ClientModel.Primitives.PipelineResponse GetRawResponse() { throw null; } + protected void SetRawResponse(System.ClientModel.Primitives.PipelineResponse response) { } } public partial class ClientResultException : System.Exception { @@ -48,11 +46,9 @@ protected internal ClientResult(T value, System.ClientModel.Primitives.PipelineR public virtual T Value { get { throw null; } } public static implicit operator T (System.ClientModel.ClientResult result) { throw null; } } - public abstract partial class EnumerableClientResult : System.ClientModel.ClientResult, System.Collections.Generic.IEnumerable, System.Collections.IEnumerable, System.IDisposable where T : System.ClientModel.Primitives.IPersistableModel + public abstract partial class EnumerableClientResult : System.ClientModel.ClientResult, System.Collections.Generic.IEnumerable, System.Collections.IEnumerable { protected internal EnumerableClientResult(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } - public void Dispose() { } - protected abstract void Dispose(bool disposing); public abstract System.Collections.Generic.IEnumerator GetEnumerator(); System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { throw null; } } diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs index 441723643ff78..77217e14abc12 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs @@ -7,12 +7,9 @@ public ApiKeyCredential(string key) { } public static implicit operator System.ClientModel.ApiKeyCredential (string key) { throw null; } public void Update(string key) { } } - public abstract partial class AsyncEnumerableResult : System.ClientModel.ClientResult, System.Collections.Generic.IAsyncEnumerable, System.IAsyncDisposable where T : System.ClientModel.Primitives.IPersistableModel + public abstract partial class AsyncEnumerableResult : System.ClientModel.ClientResult, System.Collections.Generic.IAsyncEnumerable { protected internal AsyncEnumerableResult(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } - protected abstract void Dispose(bool disposing); - public System.Threading.Tasks.ValueTask DisposeAsync() { throw null; } - protected abstract System.Threading.Tasks.ValueTask DisposeAsyncCore(); public abstract System.Collections.Generic.IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); } public abstract partial class BinaryContent : System.IDisposable @@ -28,11 +25,12 @@ protected BinaryContent() { } } public partial class ClientResult { - protected ClientResult(System.ClientModel.Primitives.PipelineResponse response) { } + protected ClientResult(System.ClientModel.Primitives.PipelineResponse? response) { } public static System.ClientModel.ClientResult FromOptionalValue(T? value, System.ClientModel.Primitives.PipelineResponse response) { throw null; } public static System.ClientModel.ClientResult FromResponse(System.ClientModel.Primitives.PipelineResponse response) { throw null; } public static System.ClientModel.ClientResult FromValue(T value, System.ClientModel.Primitives.PipelineResponse response) { throw null; } public System.ClientModel.Primitives.PipelineResponse GetRawResponse() { throw null; } + protected void SetRawResponse(System.ClientModel.Primitives.PipelineResponse response) { } } public partial class ClientResultException : System.Exception { @@ -48,11 +46,9 @@ protected internal ClientResult(T value, System.ClientModel.Primitives.PipelineR public virtual T Value { get { throw null; } } public static implicit operator T (System.ClientModel.ClientResult result) { throw null; } } - public abstract partial class EnumerableClientResult : System.ClientModel.ClientResult, System.Collections.Generic.IEnumerable, System.Collections.IEnumerable, System.IDisposable where T : System.ClientModel.Primitives.IPersistableModel + public abstract partial class EnumerableClientResult : System.ClientModel.ClientResult, System.Collections.Generic.IEnumerable, System.Collections.IEnumerable { protected internal EnumerableClientResult(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } - public void Dispose() { } - protected abstract void Dispose(bool disposing); public abstract System.Collections.Generic.IEnumerator GetEnumerator(); System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { throw null; } } diff --git a/sdk/core/System.ClientModel/src/Convenience/AsyncEnumerableResultOfT.cs b/sdk/core/System.ClientModel/src/Convenience/AsyncEnumerableResultOfT.cs index 7a1a290194d76..5a53528284d2c 100644 --- a/sdk/core/System.ClientModel/src/Convenience/AsyncEnumerableResultOfT.cs +++ b/sdk/core/System.ClientModel/src/Convenience/AsyncEnumerableResultOfT.cs @@ -4,34 +4,16 @@ using System.ClientModel.Primitives; using System.Collections.Generic; using System.Threading; -using System.Threading.Tasks; namespace System.ClientModel; #pragma warning disable CS1591 // public XML comments -public abstract class AsyncEnumerableResult : ClientResult, IAsyncEnumerable, IAsyncDisposable - where T : IPersistableModel +public abstract class AsyncEnumerableResult : ClientResult, IAsyncEnumerable { protected internal AsyncEnumerableResult(PipelineResponse response) : base(response) { } public abstract IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default); - - protected abstract ValueTask DisposeAsyncCore(); - - protected abstract void Dispose(bool disposing); - - public async ValueTask DisposeAsync() - { - await DisposeAsyncCore().ConfigureAwait(false); - - // Dispose of unmanaged resources. Note that DisposeAsyncCore disposes - // of managed resources asychronously -- we pass false to Dispose so we - // don't attempt to dispose of them synchronously as well. - Dispose(disposing: false); - - GC.SuppressFinalize(this); - } } #pragma warning restore CS1591 // public XML comments diff --git a/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs b/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs index 5f60f040fa3f6..acb6ef5ae47eb 100644 --- a/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs +++ b/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using System.ClientModel.Internal; using System.ClientModel.Primitives; namespace System.ClientModel; @@ -11,7 +10,7 @@ namespace System.ClientModel; /// public class ClientResult { - private readonly PipelineResponse _response; + private PipelineResponse? _response; /// /// Create a new instance of from a service @@ -19,10 +18,8 @@ public class ClientResult /// /// The received /// from the service. - protected ClientResult(PipelineResponse response) + protected ClientResult(PipelineResponse? response) { - Argument.AssertNotNull(response, nameof(response)); - _response = response; } @@ -31,7 +28,37 @@ protected ClientResult(PipelineResponse response) /// /// the received from the service. /// - public PipelineResponse GetRawResponse() => _response; + /// No + /// value is currently available for this + /// instance. This can happen when the instance + /// is a collection type like that + /// has not yet been enumerated. + public PipelineResponse GetRawResponse() + { + if (_response is null) + { + throw new InvalidOperationException("No response is associated " + + "with this result. If the result is a collection result " + + "type, this may be because no request has been sent to the " + + "server yet."); + } + + return _response; + } + + /// + /// Update the value returned from . + /// + /// This method may be called from types derived from + /// that poll the service for status updates + /// or to retrieve additional collection values to update the raw response + /// to the response most recently returned from the service. + /// The to return + /// from . + protected void SetRawResponse(PipelineResponse response) + { + _response = response; + } #region Factory methods for ClientResult and subtypes diff --git a/sdk/core/System.ClientModel/src/Convenience/EnumerableClientResultOfT.cs b/sdk/core/System.ClientModel/src/Convenience/EnumerableClientResultOfT.cs index e2b2565693f8d..c7e2aac9a4506 100644 --- a/sdk/core/System.ClientModel/src/Convenience/EnumerableClientResultOfT.cs +++ b/sdk/core/System.ClientModel/src/Convenience/EnumerableClientResultOfT.cs @@ -8,8 +8,7 @@ namespace System.ClientModel; #pragma warning disable CS1591 // public XML comments -public abstract class EnumerableClientResult : ClientResult, IEnumerable, IDisposable - where T : IPersistableModel +public abstract class EnumerableClientResult : ClientResult, IEnumerable { protected internal EnumerableClientResult(PipelineResponse response) : base(response) { @@ -18,13 +17,5 @@ protected internal EnumerableClientResult(PipelineResponse response) : base(resp public abstract IEnumerator GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - - public void Dispose() - { - Dispose(disposing: true); - GC.SuppressFinalize(this); - } - - protected abstract void Dispose(bool disposing); } #pragma warning restore CS1591 // public XML comments From dbf18ad9c57c2ea7ab4f617e7899743f7f51cacc Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Fri, 19 Apr 2024 17:11:07 -0700 Subject: [PATCH 04/45] rename and upate tests --- .../api/System.ClientModel.net6.0.cs | 16 ++++++++-------- .../api/System.ClientModel.netstandard2.0.cs | 16 ++++++++-------- ...T.cs => AsyncClientResultCollectionOfT.cs} | 4 ++-- .../src/Convenience/ClientResult.cs | 19 +++++++++++++++---- ...ultOfT.cs => ClientResultCollectionOfT.cs} | 4 ++-- .../tests/Convenience/ClientResultTests.cs | 2 -- 6 files changed, 35 insertions(+), 26 deletions(-) rename sdk/core/System.ClientModel/src/Convenience/{AsyncEnumerableResultOfT.cs => AsyncClientResultCollectionOfT.cs} (71%) rename sdk/core/System.ClientModel/src/Convenience/{EnumerableClientResultOfT.cs => ClientResultCollectionOfT.cs} (80%) diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs index 01a5933c6ab35..f5e87c57c3e8c 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs @@ -7,9 +7,9 @@ public ApiKeyCredential(string key) { } public static implicit operator System.ClientModel.ApiKeyCredential (string key) { throw null; } public void Update(string key) { } } - public abstract partial class AsyncEnumerableResult : System.ClientModel.ClientResult, System.Collections.Generic.IAsyncEnumerable + public abstract partial class AsyncClientResultCollection : System.ClientModel.ClientResult, System.Collections.Generic.IAsyncEnumerable { - protected internal AsyncEnumerableResult(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } + protected internal AsyncClientResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } public abstract System.Collections.Generic.IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); } public abstract partial class BinaryContent : System.IDisposable @@ -32,6 +32,12 @@ protected ClientResult(System.ClientModel.Primitives.PipelineResponse? response) public System.ClientModel.Primitives.PipelineResponse GetRawResponse() { throw null; } protected void SetRawResponse(System.ClientModel.Primitives.PipelineResponse response) { } } + public abstract partial class ClientResultCollection : System.ClientModel.ClientResult, System.Collections.Generic.IEnumerable, System.Collections.IEnumerable + { + protected internal ClientResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } + public abstract System.Collections.Generic.IEnumerator GetEnumerator(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { throw null; } + } public partial class ClientResultException : System.Exception { public ClientResultException(System.ClientModel.Primitives.PipelineResponse response, System.Exception? innerException = null) { } @@ -46,12 +52,6 @@ protected internal ClientResult(T value, System.ClientModel.Primitives.PipelineR public virtual T Value { get { throw null; } } public static implicit operator T (System.ClientModel.ClientResult result) { throw null; } } - public abstract partial class EnumerableClientResult : System.ClientModel.ClientResult, System.Collections.Generic.IEnumerable, System.Collections.IEnumerable - { - protected internal EnumerableClientResult(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } - public abstract System.Collections.Generic.IEnumerator GetEnumerator(); - System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { throw null; } - } } namespace System.ClientModel.Primitives { diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs index 77217e14abc12..1fc9b562afc27 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs @@ -7,9 +7,9 @@ public ApiKeyCredential(string key) { } public static implicit operator System.ClientModel.ApiKeyCredential (string key) { throw null; } public void Update(string key) { } } - public abstract partial class AsyncEnumerableResult : System.ClientModel.ClientResult, System.Collections.Generic.IAsyncEnumerable + public abstract partial class AsyncClientResultCollection : System.ClientModel.ClientResult, System.Collections.Generic.IAsyncEnumerable { - protected internal AsyncEnumerableResult(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } + protected internal AsyncClientResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } public abstract System.Collections.Generic.IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); } public abstract partial class BinaryContent : System.IDisposable @@ -32,6 +32,12 @@ protected ClientResult(System.ClientModel.Primitives.PipelineResponse? response) public System.ClientModel.Primitives.PipelineResponse GetRawResponse() { throw null; } protected void SetRawResponse(System.ClientModel.Primitives.PipelineResponse response) { } } + public abstract partial class ClientResultCollection : System.ClientModel.ClientResult, System.Collections.Generic.IEnumerable, System.Collections.IEnumerable + { + protected internal ClientResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } + public abstract System.Collections.Generic.IEnumerator GetEnumerator(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { throw null; } + } public partial class ClientResultException : System.Exception { public ClientResultException(System.ClientModel.Primitives.PipelineResponse response, System.Exception? innerException = null) { } @@ -46,12 +52,6 @@ protected internal ClientResult(T value, System.ClientModel.Primitives.PipelineR public virtual T Value { get { throw null; } } public static implicit operator T (System.ClientModel.ClientResult result) { throw null; } } - public abstract partial class EnumerableClientResult : System.ClientModel.ClientResult, System.Collections.Generic.IEnumerable, System.Collections.IEnumerable - { - protected internal EnumerableClientResult(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } - public abstract System.Collections.Generic.IEnumerator GetEnumerator(); - System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { throw null; } - } } namespace System.ClientModel.Primitives { diff --git a/sdk/core/System.ClientModel/src/Convenience/AsyncEnumerableResultOfT.cs b/sdk/core/System.ClientModel/src/Convenience/AsyncClientResultCollectionOfT.cs similarity index 71% rename from sdk/core/System.ClientModel/src/Convenience/AsyncEnumerableResultOfT.cs rename to sdk/core/System.ClientModel/src/Convenience/AsyncClientResultCollectionOfT.cs index 5a53528284d2c..cbfc5cc05490d 100644 --- a/sdk/core/System.ClientModel/src/Convenience/AsyncEnumerableResultOfT.cs +++ b/sdk/core/System.ClientModel/src/Convenience/AsyncClientResultCollectionOfT.cs @@ -8,9 +8,9 @@ namespace System.ClientModel; #pragma warning disable CS1591 // public XML comments -public abstract class AsyncEnumerableResult : ClientResult, IAsyncEnumerable +public abstract class AsyncClientResultCollection : ClientResult, IAsyncEnumerable { - protected internal AsyncEnumerableResult(PipelineResponse response) : base(response) + protected internal AsyncClientResultCollection(PipelineResponse response) : base(response) { } diff --git a/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs b/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs index acb6ef5ae47eb..4943f7e4ade94 100644 --- a/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs +++ b/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System.ClientModel.Internal; using System.ClientModel.Primitives; namespace System.ClientModel; @@ -31,8 +32,8 @@ protected ClientResult(PipelineResponse? response) /// No /// value is currently available for this /// instance. This can happen when the instance - /// is a collection type like that - /// has not yet been enumerated. + /// is a collection type like + /// that has not yet been enumerated. public PipelineResponse GetRawResponse() { if (_response is null) @@ -71,7 +72,11 @@ protected void SetRawResponse(PipelineResponse response) /// provided . /// public static ClientResult FromResponse(PipelineResponse response) - => new ClientResult(response); + { + Argument.AssertNotNull(response, nameof(response)); + + return new ClientResult(response); + } /// /// Creates a new instance of that holds the @@ -87,6 +92,8 @@ public static ClientResult FromResponse(PipelineResponse response) /// public static ClientResult FromValue(T value, PipelineResponse response) { + Argument.AssertNotNull(response, nameof(response)); + if (value is null) { string message = "ClientResult contract guarantees that ClientResult.Value is non-null. " + @@ -117,7 +124,11 @@ public static ClientResult FromValue(T value, PipelineResponse response) /// provided and . /// public static ClientResult FromOptionalValue(T? value, PipelineResponse response) - => new ClientResult(value, response); + { + Argument.AssertNotNull(response, nameof(response)); + + return new ClientResult(value, response); + } #endregion } diff --git a/sdk/core/System.ClientModel/src/Convenience/EnumerableClientResultOfT.cs b/sdk/core/System.ClientModel/src/Convenience/ClientResultCollectionOfT.cs similarity index 80% rename from sdk/core/System.ClientModel/src/Convenience/EnumerableClientResultOfT.cs rename to sdk/core/System.ClientModel/src/Convenience/ClientResultCollectionOfT.cs index c7e2aac9a4506..5761d53af1f15 100644 --- a/sdk/core/System.ClientModel/src/Convenience/EnumerableClientResultOfT.cs +++ b/sdk/core/System.ClientModel/src/Convenience/ClientResultCollectionOfT.cs @@ -8,9 +8,9 @@ namespace System.ClientModel; #pragma warning disable CS1591 // public XML comments -public abstract class EnumerableClientResult : ClientResult, IEnumerable +public abstract class ClientResultCollection : ClientResult, IEnumerable { - protected internal EnumerableClientResult(PipelineResponse response) : base(response) + protected internal ClientResultCollection(PipelineResponse response) : base(response) { } diff --git a/sdk/core/System.ClientModel/tests/Convenience/ClientResultTests.cs b/sdk/core/System.ClientModel/tests/Convenience/ClientResultTests.cs index ccad66170e579..629f1ec27f2b1 100644 --- a/sdk/core/System.ClientModel/tests/Convenience/ClientResultTests.cs +++ b/sdk/core/System.ClientModel/tests/Convenience/ClientResultTests.cs @@ -15,7 +15,6 @@ public class PipelineResponseTests [Test] public void CannotCreateClientResultFromNullResponse() { - Assert.Throws(() => new MockClientResult(null!)); Assert.Throws(() => { ClientResult result = ClientResult.FromResponse(null!); @@ -98,7 +97,6 @@ public void CannotCreateClientResultOfTFromNullResponse() { object value = new(); - Assert.Throws(() => new MockClientResult(value, null!)); Assert.Throws(() => { ClientResult result = ClientResult.FromValue(value, null!); From 63e1351a0ea603560e442a6f5762df1c113c60f3 Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Thu, 2 May 2024 09:43:58 -0700 Subject: [PATCH 05/45] initial addition of files from https://github.com/joseharriaga/openai-in-typespec/pull/68 --- .../SSE/AsyncServerSentEventEnumerator.cs | 67 ++++++++++ .../AsyncServerSentEventJsonDataEnumerator.cs | 71 ++++++++++ .../src/Internal/SSE/ServerSentEvent.cs | 71 ++++++++++ .../src/Internal/SSE/ServerSentEventField.cs | 67 ++++++++++ .../Internal/SSE/ServerSentEventFieldKind.cs | 14 ++ .../src/Internal/SSE/ServerSentEventReader.cs | 126 ++++++++++++++++++ .../src/Internal/SSE/StreamingClientResult.cs | 96 +++++++++++++ 7 files changed, 512 insertions(+) create mode 100644 sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs create mode 100644 sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventJsonDataEnumerator.cs create mode 100644 sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs create mode 100644 sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventField.cs create mode 100644 sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventFieldKind.cs create mode 100644 sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs create mode 100644 sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs new file mode 100644 index 0000000000000..31889214484c9 --- /dev/null +++ b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace System.ClientModel.Internal; + +internal sealed class AsyncServerSentEventEnumerator : IAsyncEnumerator, IDisposable, IAsyncDisposable +{ + private static readonly ReadOnlyMemory _doneToken = "[DONE]".AsMemory(); + + private readonly ServerSentEventReader _reader; + private CancellationToken _cancellationToken; + private bool _disposedValue; + + public ServerSentEvent Current { get; private set; } + + public AsyncServerSentEventEnumerator(ServerSentEventReader reader, CancellationToken cancellationToken = default) + { + _reader = reader; + _cancellationToken = cancellationToken; + } + + public async ValueTask MoveNextAsync() + { + ServerSentEvent? nextEvent = await _reader.TryGetNextEventAsync(_cancellationToken).ConfigureAwait(false); + if (nextEvent.HasValue) + { + if (nextEvent.Value.Data.Span.SequenceEqual(_doneToken.Span)) + { + return false; + } + Current = nextEvent.Value; + return true; + } + return false; + } + + private void Dispose(bool disposing) + { + if (!_disposedValue) + { + if (disposing) + { + _reader.Dispose(); + } + + _disposedValue = true; + } + } + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + public ValueTask DisposeAsync() + { + Dispose(); + return new ValueTask(); + } +} diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventJsonDataEnumerator.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventJsonDataEnumerator.cs new file mode 100644 index 0000000000000..756feb723113b --- /dev/null +++ b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventJsonDataEnumerator.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading.Tasks; + +namespace System.ClientModel.Internal; + +internal class AsyncServerSentEventJsonDataEnumerator : AsyncServerSentEventJsonDataEnumerator + where T : IJsonModel +{ + public AsyncServerSentEventJsonDataEnumerator(AsyncServerSentEventEnumerator eventEnumerator) : base(eventEnumerator) + { } +} + +internal class AsyncServerSentEventJsonDataEnumerator : IAsyncEnumerator, IDisposable, IAsyncDisposable + where TJsonDataType : IJsonModel +{ + private AsyncServerSentEventEnumerator _eventEnumerator; + private IEnumerator _currentInstanceEnumerator; + + public TInstanceType Current { get; private set; } + + public AsyncServerSentEventJsonDataEnumerator(AsyncServerSentEventEnumerator eventEnumerator) + { + _eventEnumerator = eventEnumerator; + } + + public async ValueTask MoveNextAsync() + { + if (_currentInstanceEnumerator?.MoveNext() == true) + { + Current = _currentInstanceEnumerator.Current; + return true; + } + if (await _eventEnumerator.MoveNextAsync()) + { + using JsonDocument eventDocument = JsonDocument.Parse(_eventEnumerator.Current.Data); + BinaryData eventData = BinaryData.FromObjectAsJson(eventDocument.RootElement); + TJsonDataType jsonData = ModelReaderWriter.Read(eventData); + if (jsonData is TInstanceType singleInstanceData) + { + Current = singleInstanceData; + return true; + } + if (jsonData is IEnumerable instanceCollectionData) + { + _currentInstanceEnumerator = instanceCollectionData.GetEnumerator(); + if (_currentInstanceEnumerator.MoveNext() == true) + { + Current = _currentInstanceEnumerator.Current; + return true; + } + } + } + return false; + } + + public async ValueTask DisposeAsync() + { + await _eventEnumerator.DisposeAsync(); + } + + public void Dispose() + { + _eventEnumerator.Dispose(); + } +} diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs new file mode 100644 index 0000000000000..3d50882ad92f9 --- /dev/null +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Text; + +namespace System.ClientModel.Internal; + +// SSE specification: https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream +internal readonly struct ServerSentEvent +{ + // Gets the value of the SSE "event type" buffer, used to distinguish between event kinds. + public ReadOnlyMemory EventName { get; } + // Gets the value of the SSE "data" buffer, which holds the payload of the server-sent event. + public ReadOnlyMemory Data { get; } + // Gets the value of the "last event ID" buffer, with which a user agent can reestablish a session. + public ReadOnlyMemory LastEventId { get; } + // If present, gets the defined "retry" value for the event, which represents the delay before reconnecting. + public TimeSpan? ReconnectionTime { get; } + + private readonly IReadOnlyList _fields; + private readonly string _multiLineData; + + internal ServerSentEvent(IReadOnlyList fields) + { + _fields = fields; + StringBuilder multiLineDataBuilder = null; + for (int i = 0; i < _fields.Count; i++) + { + ReadOnlyMemory fieldValue = _fields[i].Value; + switch (_fields[i].FieldType) + { + case ServerSentEventFieldKind.Event: + EventName = fieldValue; + break; + case ServerSentEventFieldKind.Data: + { + if (multiLineDataBuilder != null) + { + multiLineDataBuilder.Append(fieldValue); + } + else if (Data.IsEmpty) + { + Data = fieldValue; + } + else + { + multiLineDataBuilder ??= new(); + multiLineDataBuilder.Append(fieldValue); + Data = null; + } + break; + } + case ServerSentEventFieldKind.Id: + LastEventId = fieldValue; + break; + case ServerSentEventFieldKind.Retry: + ReconnectionTime = Int32.TryParse(fieldValue.ToString(), out int retry) ? TimeSpan.FromMilliseconds(retry) : null; + break; + default: + break; + } + if (multiLineDataBuilder != null) + { + _multiLineData = multiLineDataBuilder.ToString(); + Data = _multiLineData.AsMemory(); + } + } + } +} diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventField.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventField.cs new file mode 100644 index 0000000000000..1d34c556ba1b9 --- /dev/null +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventField.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; + +namespace System.ClientModel.Internal; + +// SSE specification: https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream +internal readonly struct ServerSentEventField +{ + public ServerSentEventFieldKind FieldType { get; } + + // TODO: we should not expose UTF16 publicly + public ReadOnlyMemory Value + { + get + { + if (_valueStartIndex >= _original.Length) + { + return ReadOnlyMemory.Empty; + } + else + { + return _original.AsMemory(_valueStartIndex); + } + } + } + + private readonly string _original; + private readonly int _valueStartIndex; + + internal ServerSentEventField(string line) + { + _original = line; + int colonIndex = _original.AsSpan().IndexOf(':'); + + ReadOnlyMemory fieldName = colonIndex < 0 ? _original.AsMemory(): _original.AsMemory(0, colonIndex); + FieldType = fieldName.Span switch + { + var x when x.SequenceEqual(s_eventFieldName.Span) => ServerSentEventFieldKind.Event, + var x when x.SequenceEqual(s_dataFieldName.Span) => ServerSentEventFieldKind.Data, + var x when x.SequenceEqual(s_lastEventIdFieldName.Span) => ServerSentEventFieldKind.Id, + var x when x.SequenceEqual(s_retryFieldName.Span) => ServerSentEventFieldKind.Retry, + _ => ServerSentEventFieldKind.Ignored, + }; + + if (colonIndex < 0) + { + _valueStartIndex = _original.Length; + } + else if (colonIndex + 1 < _original.Length && _original[colonIndex + 1] == ' ') + { + _valueStartIndex = colonIndex + 2; + } + else + { + _valueStartIndex = colonIndex + 1; + } + } + + public override string ToString() => _original; + + private static readonly ReadOnlyMemory s_eventFieldName = "event".AsMemory(); + private static readonly ReadOnlyMemory s_dataFieldName = "data".AsMemory(); + private static readonly ReadOnlyMemory s_lastEventIdFieldName = "id".AsMemory(); + private static readonly ReadOnlyMemory s_retryFieldName = "retry".AsMemory(); +} diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventFieldKind.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventFieldKind.cs new file mode 100644 index 0000000000000..767eeb4e6eeef --- /dev/null +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventFieldKind.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace System.ClientModel.Internal; + +// SSE specification: https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream +internal enum ServerSentEventFieldKind +{ + Event, + Data, + Id, + Retry, + Ignored +} diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs new file mode 100644 index 0000000000000..bee2fbe6c4481 --- /dev/null +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace System.ClientModel.Internal; + +internal sealed class ServerSentEventReader : IDisposable +{ + private readonly Stream _stream; + private readonly StreamReader _reader; + private bool _disposedValue; + + public ServerSentEventReader(Stream stream) + { + _stream = stream; + _reader = new StreamReader(stream); + } + + /// + /// Synchronously retrieves the next server-sent event from the underlying stream, blocking until a new event is + /// available and returning null once no further data is present on the stream. + /// + /// An optional cancellation token that can abort subsequent reads. + /// + /// The next in the stream, or null once no more data can be read from the stream. + /// + public ServerSentEvent? TryGetNextEvent(CancellationToken cancellationToken = default) + { + List fields = []; + + while (!cancellationToken.IsCancellationRequested) + { + string line = _reader.ReadLine(); + if (line == null) + { + // A null line indicates end of input + return null; + } + else if (line.Length == 0) + { + // An empty line should dispatch an event for pending accumulated fields + ServerSentEvent nextEvent = new(fields); + fields = []; + return nextEvent; + } + else if (line[0] == ':') + { + // A line beginning with a colon is a comment and should be ignored + continue; + } + else + { + // Otherwise, process the the field + value and accumulate it for the next dispatched event + fields.Add(new ServerSentEventField(line)); + } + } + + return null; + } + + /// + /// Asynchronously retrieves the next server-sent event from the underlying stream, blocking until a new event is + /// available and returning null once no further data is present on the stream. + /// + /// An optional cancellation token that can abort subsequent reads. + /// + /// The next in the stream, or null once no more data can be read from the stream. + /// + public async Task TryGetNextEventAsync(CancellationToken cancellationToken = default) + { + List fields = []; + + while (!cancellationToken.IsCancellationRequested) + { + string line = await _reader.ReadLineAsync().ConfigureAwait(false); + if (line == null) + { + // A null line indicates end of input + return null; + } + else if (line.Length == 0) + { + // An empty line should dispatch an event for pending accumulated fields + ServerSentEvent nextEvent = new(fields); + return nextEvent; + } + else if (line[0] == ':') + { + // A line beginning with a colon is a comment and should be ignored + continue; + } + else + { + // Otherwise, process the the field + value and accumulate it for the next dispatched event + fields.Add(new ServerSentEventField(line)); + } + } + + return null; + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (!_disposedValue) + { + if (disposing) + { + _reader.Dispose(); + _stream.Dispose(); + } + + _disposedValue = true; + } + } +} diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs b/sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs new file mode 100644 index 0000000000000..daf5b59af7f26 --- /dev/null +++ b/sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.IO; +using System.Threading; + +namespace System.ClientModel.Internal; + +#pragma warning disable CS1591 // public XML comments +/// +/// Represents an operation response with streaming content that can be deserialized and enumerated while the response +/// is still being received. +/// +/// The data type representative of distinct, streamable items. +public class StreamingClientResult : IAsyncEnumerable +{ + private readonly PipelineResponse _response; + private readonly Func> _asyncEnumeratorSourceDelegate; + + private bool _disposedValue; + + /// + /// Gets the underlying that contains headers and other response-wide information. + /// + /// + /// The instance used in this . + /// + public PipelineResponse GetRawResponse() => _response; + + private StreamingClientResult(PipelineResponse response, Func> asyncEnumeratorSourceDelegate) + { + _response = response; + _asyncEnumeratorSourceDelegate = asyncEnumeratorSourceDelegate; + } + + /// + /// Creates a new instance of that will yield items of the specified type + /// as they become available via server-sent event JSON data on the available + /// . This overload uses via the + /// interface and only supports single-item deserialization per server-sent event data + /// payload. + /// + /// The base for this result instance. + /// + /// The optional cancellation token used to control the enumeration. + /// + /// A new instance of . + public static StreamingClientResult Create(PipelineResponse response, CancellationToken cancellationToken = default) + where U : IJsonModel + { + return new(response, GetServerSentEventDeserializationEnumerator); + } + + public static StreamingClientResult Create(PipelineResponse response, CancellationToken cancellationToken = default) + where TJsonDataType : IJsonModel + { + return new(response, GetServerSentEventDeserializationEnumerator); + } + + private static IAsyncEnumerator GetServerSentEventDeserializationEnumerator(Stream stream, CancellationToken cancellationToken = default) + where U : IJsonModel + { + return GetServerSentEventDeserializationEnumerator(stream, cancellationToken); + } + + private static IAsyncEnumerator GetServerSentEventDeserializationEnumerator( + Stream stream, + CancellationToken cancellationToken = default) + where TJsonDataType : IJsonModel + { + ServerSentEventReader sseReader = null; + AsyncServerSentEventEnumerator sseEnumerator = null; + try + { + sseReader = new(stream); + sseEnumerator = new(sseReader, cancellationToken); + AsyncServerSentEventJsonDataEnumerator instanceEnumerator = new(sseEnumerator); + sseEnumerator = null; + sseReader = null; + return instanceEnumerator; + } + finally + { + sseEnumerator?.Dispose(); + sseReader?.Dispose(); + } + } + + IAsyncEnumerator IAsyncEnumerable.GetAsyncEnumerator(CancellationToken cancellationToken) + { + return _asyncEnumeratorSourceDelegate.Invoke(_response.ContentStream, cancellationToken); + } +} +#pragma warning restore CS1591 // public XML comments From f6045989f4ac044f18f72e3d1ac1966c0ab9a6cc Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Thu, 2 May 2024 10:05:46 -0700 Subject: [PATCH 06/45] make it build --- .../SSE/AsyncServerSentEventEnumerator.cs | 4 +-- .../AsyncServerSentEventJsonDataEnumerator.cs | 33 +++++++++++++------ .../src/Internal/SSE/ServerSentEvent.cs | 5 ++- .../src/Internal/SSE/ServerSentEventField.cs | 2 -- .../Internal/SSE/ServerSentEventFieldKind.cs | 1 + .../src/Internal/SSE/ServerSentEventReader.cs | 11 +++---- .../src/Internal/SSE/StreamingClientResult.cs | 16 ++++++--- 7 files changed, 45 insertions(+), 27 deletions(-) diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs index 31889214484c9..d2011b8768549 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -10,10 +9,11 @@ namespace System.ClientModel.Internal; internal sealed class AsyncServerSentEventEnumerator : IAsyncEnumerator, IDisposable, IAsyncDisposable { + // TODO: make this configurable per coming from TypeSpec private static readonly ReadOnlyMemory _doneToken = "[DONE]".AsMemory(); private readonly ServerSentEventReader _reader; - private CancellationToken _cancellationToken; + private readonly CancellationToken _cancellationToken; private bool _disposedValue; public ServerSentEvent Current { get; private set; } diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventJsonDataEnumerator.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventJsonDataEnumerator.cs index 756feb723113b..45bfb559bf61e 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventJsonDataEnumerator.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventJsonDataEnumerator.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using System; using System.ClientModel.Primitives; using System.Collections.Generic; using System.Text.Json; @@ -19,13 +18,18 @@ public AsyncServerSentEventJsonDataEnumerator(AsyncServerSentEventEnumerator eve internal class AsyncServerSentEventJsonDataEnumerator : IAsyncEnumerator, IDisposable, IAsyncDisposable where TJsonDataType : IJsonModel { - private AsyncServerSentEventEnumerator _eventEnumerator; - private IEnumerator _currentInstanceEnumerator; + private readonly AsyncServerSentEventEnumerator _eventEnumerator; + private IEnumerator? _currentInstanceEnumerator; - public TInstanceType Current { get; private set; } + private TInstanceType? _current; + + // TODO: is null supression the correct pattern here? + public TInstanceType Current { get => _current!; } public AsyncServerSentEventJsonDataEnumerator(AsyncServerSentEventEnumerator eventEnumerator) { + Argument.AssertNotNull(eventEnumerator, nameof(eventEnumerator)); + _eventEnumerator = eventEnumerator; } @@ -33,25 +37,34 @@ public async ValueTask MoveNextAsync() { if (_currentInstanceEnumerator?.MoveNext() == true) { - Current = _currentInstanceEnumerator.Current; + _current = _currentInstanceEnumerator.Current; return true; } - if (await _eventEnumerator.MoveNextAsync()) + + if (await _eventEnumerator.MoveNextAsync().ConfigureAwait(false)) { using JsonDocument eventDocument = JsonDocument.Parse(_eventEnumerator.Current.Data); BinaryData eventData = BinaryData.FromObjectAsJson(eventDocument.RootElement); - TJsonDataType jsonData = ModelReaderWriter.Read(eventData); + TJsonDataType? jsonData = ModelReaderWriter.Read(eventData); + + if (jsonData is null) + { + _current = default; + return false; + } + if (jsonData is TInstanceType singleInstanceData) { - Current = singleInstanceData; + _current = singleInstanceData; return true; } + if (jsonData is IEnumerable instanceCollectionData) { _currentInstanceEnumerator = instanceCollectionData.GetEnumerator(); if (_currentInstanceEnumerator.MoveNext() == true) { - Current = _currentInstanceEnumerator.Current; + _current = _currentInstanceEnumerator.Current; return true; } } @@ -61,7 +74,7 @@ public async ValueTask MoveNextAsync() public async ValueTask DisposeAsync() { - await _eventEnumerator.DisposeAsync(); + await _eventEnumerator.DisposeAsync().ConfigureAwait(false); } public void Dispose() diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs index 3d50882ad92f9..57538c9aa6694 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using System; using System.Collections.Generic; using System.Text; @@ -20,12 +19,12 @@ internal readonly struct ServerSentEvent public TimeSpan? ReconnectionTime { get; } private readonly IReadOnlyList _fields; - private readonly string _multiLineData; + private readonly string? _multiLineData; internal ServerSentEvent(IReadOnlyList fields) { _fields = fields; - StringBuilder multiLineDataBuilder = null; + StringBuilder? multiLineDataBuilder = null; for (int i = 0; i < _fields.Count; i++) { ReadOnlyMemory fieldValue = _fields[i].Value; diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventField.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventField.cs index 1d34c556ba1b9..c484946fa37f0 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventField.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventField.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using System; - namespace System.ClientModel.Internal; // SSE specification: https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventFieldKind.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventFieldKind.cs index 767eeb4e6eeef..48d1884a2a7a5 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventFieldKind.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventFieldKind.cs @@ -6,6 +6,7 @@ namespace System.ClientModel.Internal; // SSE specification: https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream internal enum ServerSentEventFieldKind { + // TODO: zero value? Event, Data, Id, diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs index bee2fbe6c4481..06df05cc396b8 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using System; using System.Collections.Generic; using System.IO; using System.Threading; @@ -31,11 +30,11 @@ public ServerSentEventReader(Stream stream) /// public ServerSentEvent? TryGetNextEvent(CancellationToken cancellationToken = default) { - List fields = []; + List fields = new(); while (!cancellationToken.IsCancellationRequested) { - string line = _reader.ReadLine(); + string? line = _reader.ReadLine(); if (line == null) { // A null line indicates end of input @@ -45,7 +44,7 @@ public ServerSentEventReader(Stream stream) { // An empty line should dispatch an event for pending accumulated fields ServerSentEvent nextEvent = new(fields); - fields = []; + fields = new(); return nextEvent; } else if (line[0] == ':') @@ -73,11 +72,11 @@ public ServerSentEventReader(Stream stream) /// public async Task TryGetNextEventAsync(CancellationToken cancellationToken = default) { - List fields = []; + List fields = new(); while (!cancellationToken.IsCancellationRequested) { - string line = await _reader.ReadLineAsync().ConfigureAwait(false); + string? line = await _reader.ReadLineAsync().ConfigureAwait(false); if (line == null) { // A null line indicates end of input diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs b/sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs index daf5b59af7f26..8fdf3a4f5a0f8 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs @@ -19,7 +19,8 @@ public class StreamingClientResult : IAsyncEnumerable private readonly PipelineResponse _response; private readonly Func> _asyncEnumeratorSourceDelegate; - private bool _disposedValue; + // TODO: use? + //private bool _disposedValue; /// /// Gets the underlying that contains headers and other response-wide information. @@ -31,6 +32,13 @@ public class StreamingClientResult : IAsyncEnumerable private StreamingClientResult(PipelineResponse response, Func> asyncEnumeratorSourceDelegate) { + Argument.AssertNotNull(response, nameof(response)); + + if (response.ContentStream is null) + { + throw new ArgumentException("Unable to create result from response with null ContentStream", nameof(response)); + } + _response = response; _asyncEnumeratorSourceDelegate = asyncEnumeratorSourceDelegate; } @@ -70,8 +78,8 @@ private static IAsyncEnumerator GetServerSentEventDeserialization CancellationToken cancellationToken = default) where TJsonDataType : IJsonModel { - ServerSentEventReader sseReader = null; - AsyncServerSentEventEnumerator sseEnumerator = null; + ServerSentEventReader? sseReader = null; + AsyncServerSentEventEnumerator? sseEnumerator = null; try { sseReader = new(stream); @@ -90,7 +98,7 @@ private static IAsyncEnumerator GetServerSentEventDeserialization IAsyncEnumerator IAsyncEnumerable.GetAsyncEnumerator(CancellationToken cancellationToken) { - return _asyncEnumeratorSourceDelegate.Invoke(_response.ContentStream, cancellationToken); + return _asyncEnumeratorSourceDelegate.Invoke(_response.ContentStream!, cancellationToken); } } #pragma warning restore CS1591 // public XML comments From d3bd6b89406f85a29c307c3e3c7672cc2b68b4b8 Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Thu, 2 May 2024 10:39:50 -0700 Subject: [PATCH 07/45] hello world test --- .../api/System.ClientModel.net6.0.cs | 7 +-- .../api/System.ClientModel.netstandard2.0.cs | 7 +-- .../AsyncClientResultCollectionOfT.cs | 8 +++ .../Convenience/ClientResultCollectionOfT.cs | 30 +++++++---- .../src/Internal/SSE/StreamingClientResult.cs | 19 ++----- .../ClientResultCollectionTests.cs | 37 +++++++++++++ .../tests/Convenience/ClientResultTests.cs | 45 ---------------- .../tests/TestFramework/Mocks/MockClient.cs | 52 +++++++++++++++++++ 8 files changed, 123 insertions(+), 82 deletions(-) create mode 100644 sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs create mode 100644 sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs index f5e87c57c3e8c..38f6b92d7e3a2 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs @@ -10,6 +10,7 @@ public void Update(string key) { } public abstract partial class AsyncClientResultCollection : System.ClientModel.ClientResult, System.Collections.Generic.IAsyncEnumerable { protected internal AsyncClientResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } + public static System.ClientModel.AsyncClientResultCollection Create(System.ClientModel.Primitives.PipelineResponse response) where TValue : System.ClientModel.Primitives.IJsonModel { throw null; } public abstract System.Collections.Generic.IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); } public abstract partial class BinaryContent : System.IDisposable @@ -32,12 +33,6 @@ protected ClientResult(System.ClientModel.Primitives.PipelineResponse? response) public System.ClientModel.Primitives.PipelineResponse GetRawResponse() { throw null; } protected void SetRawResponse(System.ClientModel.Primitives.PipelineResponse response) { } } - public abstract partial class ClientResultCollection : System.ClientModel.ClientResult, System.Collections.Generic.IEnumerable, System.Collections.IEnumerable - { - protected internal ClientResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } - public abstract System.Collections.Generic.IEnumerator GetEnumerator(); - System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { throw null; } - } public partial class ClientResultException : System.Exception { public ClientResultException(System.ClientModel.Primitives.PipelineResponse response, System.Exception? innerException = null) { } diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs index 1fc9b562afc27..8b08908c85907 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs @@ -10,6 +10,7 @@ public void Update(string key) { } public abstract partial class AsyncClientResultCollection : System.ClientModel.ClientResult, System.Collections.Generic.IAsyncEnumerable { protected internal AsyncClientResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } + public static System.ClientModel.AsyncClientResultCollection Create(System.ClientModel.Primitives.PipelineResponse response) where TValue : System.ClientModel.Primitives.IJsonModel { throw null; } public abstract System.Collections.Generic.IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); } public abstract partial class BinaryContent : System.IDisposable @@ -32,12 +33,6 @@ protected ClientResult(System.ClientModel.Primitives.PipelineResponse? response) public System.ClientModel.Primitives.PipelineResponse GetRawResponse() { throw null; } protected void SetRawResponse(System.ClientModel.Primitives.PipelineResponse response) { } } - public abstract partial class ClientResultCollection : System.ClientModel.ClientResult, System.Collections.Generic.IEnumerable, System.Collections.IEnumerable - { - protected internal ClientResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } - public abstract System.Collections.Generic.IEnumerator GetEnumerator(); - System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { throw null; } - } public partial class ClientResultException : System.Exception { public ClientResultException(System.ClientModel.Primitives.PipelineResponse response, System.Exception? innerException = null) { } diff --git a/sdk/core/System.ClientModel/src/Convenience/AsyncClientResultCollectionOfT.cs b/sdk/core/System.ClientModel/src/Convenience/AsyncClientResultCollectionOfT.cs index cbfc5cc05490d..53845f3b9898e 100644 --- a/sdk/core/System.ClientModel/src/Convenience/AsyncClientResultCollectionOfT.cs +++ b/sdk/core/System.ClientModel/src/Convenience/AsyncClientResultCollectionOfT.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System.ClientModel.Internal; using System.ClientModel.Primitives; using System.Collections.Generic; using System.Threading; @@ -15,5 +16,12 @@ protected internal AsyncClientResultCollection(PipelineResponse response) : base } public abstract IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default); + + // TODO: take CancellationToken? + //public static ClientResultCollection Create(PipelineResponse response) where TValue : IJsonModel + public static AsyncClientResultCollection Create(PipelineResponse response) where TValue : IJsonModel + { + return StreamingClientResult.Create(response); + } } #pragma warning restore CS1591 // public XML comments diff --git a/sdk/core/System.ClientModel/src/Convenience/ClientResultCollectionOfT.cs b/sdk/core/System.ClientModel/src/Convenience/ClientResultCollectionOfT.cs index 5761d53af1f15..b416813377c93 100644 --- a/sdk/core/System.ClientModel/src/Convenience/ClientResultCollectionOfT.cs +++ b/sdk/core/System.ClientModel/src/Convenience/ClientResultCollectionOfT.cs @@ -1,21 +1,31 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System.ClientModel.Internal; using System.ClientModel.Primitives; using System.Collections; using System.Collections.Generic; namespace System.ClientModel; -#pragma warning disable CS1591 // public XML comments -public abstract class ClientResultCollection : ClientResult, IEnumerable -{ - protected internal ClientResultCollection(PipelineResponse response) : base(response) - { - } +// TODO: Re-enable sync version - public abstract IEnumerator GetEnumerator(); +//#pragma warning disable CS1591 // public XML comments +//public abstract class ClientResultCollection : ClientResult, IEnumerable +//{ +// protected internal ClientResultCollection(PipelineResponse response) : base(response) +// { +// } - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); -} -#pragma warning restore CS1591 // public XML comments +// public abstract IEnumerator GetEnumerator(); + +// IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + +// // TODO: take CancellationToken? +// //public static ClientResultCollection Create(PipelineResponse response) where TValue : IJsonModel +// public static ClientResultCollection Create(PipelineResponse response) where TValue : IJsonModel +// { +// return StreamingClientResult.Create(response); +// } +//} +//#pragma warning restore CS1591 // public XML comments diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs b/sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs index 8fdf3a4f5a0f8..49aaf033ddfe6 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs @@ -8,29 +8,20 @@ namespace System.ClientModel.Internal; -#pragma warning disable CS1591 // public XML comments /// /// Represents an operation response with streaming content that can be deserialized and enumerated while the response /// is still being received. /// /// The data type representative of distinct, streamable items. -public class StreamingClientResult : IAsyncEnumerable +internal class StreamingClientResult : AsyncClientResultCollection { - private readonly PipelineResponse _response; private readonly Func> _asyncEnumeratorSourceDelegate; // TODO: use? //private bool _disposedValue; - /// - /// Gets the underlying that contains headers and other response-wide information. - /// - /// - /// The instance used in this . - /// - public PipelineResponse GetRawResponse() => _response; - private StreamingClientResult(PipelineResponse response, Func> asyncEnumeratorSourceDelegate) + : base(response) { Argument.AssertNotNull(response, nameof(response)); @@ -39,7 +30,6 @@ private StreamingClientResult(PipelineResponse response, Func GetServerSentEventDeserialization } } - IAsyncEnumerator IAsyncEnumerable.GetAsyncEnumerator(CancellationToken cancellationToken) + public override IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken) { - return _asyncEnumeratorSourceDelegate.Invoke(_response.ContentStream!, cancellationToken); + return _asyncEnumeratorSourceDelegate.Invoke(GetRawResponse().ContentStream!, cancellationToken); } } -#pragma warning restore CS1591 // public XML comments diff --git a/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs b/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs new file mode 100644 index 0000000000000..21468bfa6db4a --- /dev/null +++ b/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Threading.Tasks; +using Azure.Core.TestFramework; +using ClientModel.Tests.Mocks; +using NUnit.Framework; +using SyncAsyncTestBase = ClientModel.Tests.SyncAsyncTestBase; + +namespace System.ClientModel.Tests.Results; + +public class ClientResultCollectionTests : SyncAsyncTestBase +{ + public ClientResultCollectionTests(bool isAsync) : base(isAsync) + { + } + + [Test] + public async Task CreatesAsyncResultCollection() + { + MockPipelineResponse response = new(); + response.SetContent("[DONE]"); + + AsyncClientResultCollection results = + AsyncClientResultCollection.Create(response); + + bool empty = true; + await foreach (MockJsonModel result in results) + { + empty = false; + } + + Assert.IsNotNull(results); + Assert.AreEqual(results.GetRawResponse(), response); + Assert.IsTrue(empty); + } +} diff --git a/sdk/core/System.ClientModel/tests/Convenience/ClientResultTests.cs b/sdk/core/System.ClientModel/tests/Convenience/ClientResultTests.cs index 629f1ec27f2b1..381404a0399fb 100644 --- a/sdk/core/System.ClientModel/tests/Convenience/ClientResultTests.cs +++ b/sdk/core/System.ClientModel/tests/Convenience/ClientResultTests.cs @@ -257,51 +257,6 @@ public DerivedClientResult(T value, PipelineResponse response) : base(value, res } } - internal class MockClient - { - public virtual ClientResult GetModel(int intValue, string stringValue) - { - MockPipelineResponse response = new(200); - MockJsonModel model = new MockJsonModel(intValue, stringValue); - return ClientResult.FromValue(model, response); - } - - public virtual ClientResult GetOptionalModel(int intValue, string stringValue, bool hasValue) - { - if (hasValue) - { - MockPipelineResponse response = new(200); - MockJsonModel model = new MockJsonModel(intValue, stringValue); - return ClientResult.FromOptionalValue(model, response); - } - else - { - MockPipelineResponse response = new(404); - return ClientResult.FromOptionalValue(default, response); - } - } - - public virtual ClientResult GetCount(int count) - { - MockPipelineResponse response = new(200); - return ClientResult.FromValue(count, response); - } - - public virtual ClientResult GetOptionalCount(int count, bool hasValue) - { - if (hasValue) - { - MockPipelineResponse response = new(200); - return ClientResult.FromOptionalValue(count, response); - } - else - { - MockPipelineResponse response = new(404); - return ClientResult.FromOptionalValue(default, response); - } - } - } - internal class CastableClientResult : ClientResult { protected internal CastableClientResult(T value, PipelineResponse response) : base(value, response) diff --git a/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs b/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs new file mode 100644 index 0000000000000..135588c933359 --- /dev/null +++ b/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ClientModel; +using Azure.Core.TestFramework; + +namespace ClientModel.Tests.Mocks; + +internal class MockClient +{ + public virtual ClientResult GetModel(int intValue, string stringValue) + { + MockPipelineResponse response = new(200); + MockJsonModel model = new MockJsonModel(intValue, stringValue); + return ClientResult.FromValue(model, response); + } + + public virtual ClientResult GetOptionalModel(int intValue, string stringValue, bool hasValue) + { + if (hasValue) + { + MockPipelineResponse response = new(200); + MockJsonModel model = new MockJsonModel(intValue, stringValue); + return ClientResult.FromOptionalValue(model, response); + } + else + { + MockPipelineResponse response = new(404); + return ClientResult.FromOptionalValue(default, response); + } + } + + public virtual ClientResult GetCount(int count) + { + MockPipelineResponse response = new(200); + return ClientResult.FromValue(count, response); + } + + public virtual ClientResult GetOptionalCount(int count, bool hasValue) + { + if (hasValue) + { + MockPipelineResponse response = new(200); + return ClientResult.FromOptionalValue(count, response); + } + else + { + MockPipelineResponse response = new(404); + return ClientResult.FromOptionalValue(default, response); + } + } +} From 3fca0286c267dbbc6cb6e274eb96febcc9ea2980 Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Thu, 2 May 2024 11:26:14 -0700 Subject: [PATCH 08/45] bootstrap more tests --- .../src/Internal/SSE/ServerSentEvent.cs | 2 +- .../tests/TestFramework/Mocks/MockClient.cs | 6 ++++ .../Convenience/ServerSentEventFieldTests.cs | 21 +++++++++++++ .../Convenience/ServerSentEventReaderTests.cs | 14 +++++++++ .../Convenience/ServerSentEventTests.cs | 30 +++++++++++++++++++ 5 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventFieldTests.cs create mode 100644 sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventReaderTests.cs create mode 100644 sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventTests.cs diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs index 57538c9aa6694..91e355c1b279c 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs @@ -55,7 +55,7 @@ internal ServerSentEvent(IReadOnlyList fields) LastEventId = fieldValue; break; case ServerSentEventFieldKind.Retry: - ReconnectionTime = Int32.TryParse(fieldValue.ToString(), out int retry) ? TimeSpan.FromMilliseconds(retry) : null; + ReconnectionTime = int.TryParse(fieldValue.ToString(), out int retry) ? TimeSpan.FromMilliseconds(retry) : null; break; default: break; diff --git a/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs b/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs index 135588c933359..20e25ad2da12d 100644 --- a/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs +++ b/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.ClientModel; +using System.Collections.Generic; using Azure.Core.TestFramework; namespace ClientModel.Tests.Mocks; @@ -49,4 +50,9 @@ public virtual ClientResult GetCount(int count) return ClientResult.FromOptionalValue(default, response); } } + + //public virtual AsyncClientResultCollection GetSseModels(IEnumerable models) + //{ + + //} } diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventFieldTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventFieldTests.cs new file mode 100644 index 0000000000000..6e431ac53cb7a --- /dev/null +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventFieldTests.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ClientModel.Internal; +using NUnit.Framework; + +namespace System.ClientModel.Tests.Convenience; + +public class ServerSentEventFieldTests +{ + [Test] + public void ParsesEventField() + { + string line = "event: event.name"; + ServerSentEventField field = new(line); + + Assert.AreEqual(field.ToString(), line); + Assert.AreEqual(field.FieldType, ServerSentEventFieldKind.Event); + Assert.IsTrue(field.Value.Span.SequenceEqual("event.name".AsMemory().Span)); + } +} diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventReaderTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventReaderTests.cs new file mode 100644 index 0000000000000..5736e16264c6a --- /dev/null +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventReaderTests.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace System.ClientModel.Tests.Convenience; + +public class ServerSentEventReaderTests +{ +} diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventTests.cs new file mode 100644 index 0000000000000..b765b453d333c --- /dev/null +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventTests.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ClientModel.Internal; +using System.Collections.Generic; +using NUnit.Framework; + +namespace System.ClientModel.Tests.Convenience; + +public class ServerSentEventTests +{ + [Test] + public void ParsesEventField() + { + string eventLine = "event: event.name"; + string dataLine = """data: {"id":"a","object":"value"}"""; + + List fields = new() { + new ServerSentEventField(eventLine), + new ServerSentEventField(dataLine) + }; + + ServerSentEvent ssEvent = new(fields); + + Assert.IsNull(ssEvent.ReconnectionTime); + Assert.IsTrue(ssEvent.EventName.Span.SequenceEqual("event.name".AsMemory().Span)); + Assert.IsTrue(ssEvent.Data.Span.SequenceEqual("""{"id":"a","object":"value"}""".AsMemory().Span)); + Assert.AreEqual(ssEvent.LastEventId.Length, 0); + } +} From 4ea146da98a13c4975979432aedeee73a36df668 Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Thu, 2 May 2024 13:55:23 -0700 Subject: [PATCH 09/45] more internal tests --- .../src/Internal/SSE/ServerSentEventReader.cs | 1 + .../Convenience/ServerSentEventFieldTests.cs | 2 +- .../Convenience/ServerSentEventReaderTests.cs | 49 +++++++++++++++++-- .../Convenience/ServerSentEventTests.cs | 6 +-- 4 files changed, 51 insertions(+), 7 deletions(-) diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs index 06df05cc396b8..b423a3747a7f1 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs @@ -28,6 +28,7 @@ public ServerSentEventReader(Stream stream) /// /// The next in the stream, or null once no more data can be read from the stream. /// + // TODO: Would we rather use standard .NET TryGet semantics? public ServerSentEvent? TryGetNextEvent(CancellationToken cancellationToken = default) { List fields = new(); diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventFieldTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventFieldTests.cs index 6e431ac53cb7a..acac0f6056f9a 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventFieldTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventFieldTests.cs @@ -16,6 +16,6 @@ public void ParsesEventField() Assert.AreEqual(field.ToString(), line); Assert.AreEqual(field.FieldType, ServerSentEventFieldKind.Event); - Assert.IsTrue(field.Value.Span.SequenceEqual("event.name".AsMemory().Span)); + Assert.IsTrue(field.Value.Span.SequenceEqual("event.name".AsSpan())); } } diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventReaderTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventReaderTests.cs index 5736e16264c6a..e010d3b996fe5 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventReaderTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventReaderTests.cs @@ -1,14 +1,57 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using System; +using System.ClientModel.Internal; using System.Collections.Generic; -using System.Linq; -using System.Text; +using System.IO; using System.Threading.Tasks; +using NUnit.Framework; namespace System.ClientModel.Tests.Convenience; public class ServerSentEventReaderTests { + // TODO: Test both sync and async + + [Test] + public async Task GetsEventsFromStream() + { + Stream contentStream = BinaryData.FromString(_mockContent).ToStream(); + using ServerSentEventReader reader = new(contentStream); + + List events = new(); + ServerSentEvent? ssEvent = await reader.TryGetNextEventAsync(); + while (ssEvent is not null) + { + events.Add(ssEvent.Value); + ssEvent = await reader.TryGetNextEventAsync(); + } + + Assert.AreEqual(events.Count, 3); + + for (int i = 0; i < events.Count; i++) + { + ServerSentEvent sse = events[i]; + Assert.IsTrue(sse.EventName.Span.SequenceEqual($"event.{i}".AsSpan())); + Assert.IsTrue(sse.Data.Span.SequenceEqual($"{{ \"id\": \"{i}\", \"object\": {i} }}".AsSpan())); + } + } + + #region Helpers + + private string _mockContent = """ + event: event.0 + data: { "id": "0", "object": 0 } + + event: event.1 + data: { "id": "1", "object": 1 } + + event: event.2 + data: { "id": "2", "object": 2 } + + event: done + data: [DONE] + """; + + #endregion } diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventTests.cs index b765b453d333c..63ecc7c6bb82c 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventTests.cs @@ -10,7 +10,7 @@ namespace System.ClientModel.Tests.Convenience; public class ServerSentEventTests { [Test] - public void ParsesEventField() + public void SetsPropertiesFromFields() { string eventLine = "event: event.name"; string dataLine = """data: {"id":"a","object":"value"}"""; @@ -23,8 +23,8 @@ public void ParsesEventField() ServerSentEvent ssEvent = new(fields); Assert.IsNull(ssEvent.ReconnectionTime); - Assert.IsTrue(ssEvent.EventName.Span.SequenceEqual("event.name".AsMemory().Span)); - Assert.IsTrue(ssEvent.Data.Span.SequenceEqual("""{"id":"a","object":"value"}""".AsMemory().Span)); + Assert.IsTrue(ssEvent.EventName.Span.SequenceEqual("event.name".AsSpan())); + Assert.IsTrue(ssEvent.Data.Span.SequenceEqual("""{"id":"a","object":"value"}""".AsSpan())); Assert.AreEqual(ssEvent.LastEventId.Length, 0); } } From 262c78df6528877dcd749a5c0d19119290eacaeb Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Thu, 2 May 2024 15:22:02 -0700 Subject: [PATCH 10/45] adding enumerator tests; haven't figured out the batch piece yet --- .../AsyncServerSentEventEnumeratorTests.cs | 55 ++++++++ ...cServerSentEventJsonDataEnumeratorTests.cs | 131 ++++++++++++++++++ .../Convenience/ServerSentEventReaderTests.cs | 3 + 3 files changed, 189 insertions(+) create mode 100644 sdk/core/System.ClientModel/tests/internal/Convenience/AsyncServerSentEventEnumeratorTests.cs create mode 100644 sdk/core/System.ClientModel/tests/internal/Convenience/AsyncServerSentEventJsonDataEnumeratorTests.cs diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/AsyncServerSentEventEnumeratorTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/AsyncServerSentEventEnumeratorTests.cs new file mode 100644 index 0000000000000..c8eb562feabe1 --- /dev/null +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/AsyncServerSentEventEnumeratorTests.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ClientModel.Internal; +using System.IO; +using System.Threading.Tasks; +using NUnit.Framework; + +namespace System.ClientModel.Tests.Convenience; + +public class AsyncServerSentEventEnumeratorTests +{ + [Test] + public async Task EnumeratesSingleEvents() + { + Stream contentStream = BinaryData.FromString(_mockContent).ToStream(); + using ServerSentEventReader reader = new(contentStream); + using AsyncServerSentEventEnumerator enumerator = new(reader); + + int i = 0; + while (await enumerator.MoveNextAsync()) + { + ServerSentEvent sse = enumerator.Current; + + Assert.IsTrue(sse.EventName.Span.SequenceEqual($"event.{i}".AsSpan())); + Assert.IsTrue(sse.Data.Span.SequenceEqual($"{{ \"id\": \"{i}\", \"object\": {i} }}".AsSpan())); + + i++; + } + + Assert.AreEqual(i, 3); + } + + // TODO: Add tests for dispose and handling cancellation token + // TODO: later, add tests for varying the _doneToken value. + + #region Helpers + + private string _mockContent = """ + event: event.0 + data: { "id": "0", "object": 0 } + + event: event.1 + data: { "id": "1", "object": 1 } + + event: event.2 + data: { "id": "2", "object": 2 } + + event: done + data: [DONE] + + """; + + #endregion +} diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/AsyncServerSentEventJsonDataEnumeratorTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/AsyncServerSentEventJsonDataEnumeratorTests.cs new file mode 100644 index 0000000000000..660087afcde56 --- /dev/null +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/AsyncServerSentEventJsonDataEnumeratorTests.cs @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ClientModel.Internal; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using Azure.Core.TestFramework; +using NUnit.Framework; + +namespace System.ClientModel.Tests.Convenience; + +public class AsyncServerSentEventJsonDataEnumeratorTests +{ + [Test] + public async Task EnumeratesSingleEvents() + { + Stream contentStream = BinaryData.FromString(_mockSingleEventContent).ToStream(); + using ServerSentEventReader reader = new(contentStream); + using AsyncServerSentEventEnumerator eventEnumerator = new(reader); + using AsyncServerSentEventJsonDataEnumerator modelEnumerator = new(eventEnumerator); + + int i = 0; + while (await modelEnumerator.MoveNextAsync()) + { + MockJsonModel model = modelEnumerator.Current; + + Assert.AreEqual(model.IntValue, i); + Assert.AreEqual(model.StringValue, i.ToString()); + + i++; + } + + Assert.AreEqual(i, 3); + } + + [Test] + public async Task EnumeratesBatchEvents() + { + Stream contentStream = BinaryData.FromString(_mockBatchEventContent).ToStream(); + using ServerSentEventReader reader = new(contentStream); + using AsyncServerSentEventEnumerator eventEnumerator = new(reader); + using AsyncServerSentEventJsonDataEnumerator modelEnumerator = new(eventEnumerator); + + int i = 0; + while (await modelEnumerator.MoveNextAsync()) + { + MockJsonModel model = modelEnumerator.Current; + + Assert.AreEqual(model.IntValue, i); + Assert.AreEqual(model.StringValue, i.ToString()); + + i++; + } + + Assert.AreEqual(i, 6); + } + + // TODO: Add tests for dispose and handling cancellation token + // TODO: later, add tests for varying the _doneToken value. + // TODO: tests for infinite stream -- no terminal event; how to show it won't stop? + + #region Helpers + + private string _mockSingleEventContent = """ + event: event.0 + data: { "IntValue": 0, "StringValue": "0" } + + event: event.1 + data: { "IntValue": 1, "StringValue": "1" } + + event: event.2 + data: { "IntValue": 2, "StringValue": "2" } + + event: done + data: [DONE] + + """; + + private string _mockBatchEventContent = """ + event: event.0 + data: { { "IntValue": 0, "StringValue": "0" }, { "IntValue": 1, "StringValue": "1" } } + + event: event.1 + data: { "IntValue": 2, "StringValue": "2" } + + event: event.2 + data: { { "IntValue": 3, "StringValue": "3" }, { "IntValue": 4, "StringValue": "4" }, { "IntValue": 5, "StringValue": "5" } } + + event: done + data: [DONE] + + """; + + private class MockJsonModelCollection : ReadOnlyCollection, IJsonModel + { + public MockJsonModelCollection(IList models) : base(models) + { + } + + MockJsonModelCollection IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + throw new NotImplementedException(); + } + + MockJsonModelCollection IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) + { + throw new NotImplementedException(); + } + + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) + { + throw new NotImplementedException(); + } + + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + throw new NotImplementedException(); + } + + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) + { + throw new NotImplementedException(); + } + } + + #endregion +} diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventReaderTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventReaderTests.cs index e010d3b996fe5..173afbfba46b2 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventReaderTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventReaderTests.cs @@ -35,6 +35,8 @@ public async Task GetsEventsFromStream() Assert.IsTrue(sse.EventName.Span.SequenceEqual($"event.{i}".AsSpan())); Assert.IsTrue(sse.Data.Span.SequenceEqual($"{{ \"id\": \"{i}\", \"object\": {i} }}".AsSpan())); } + + // TODO: Question - should this include the "done" event? Probably yes? } #region Helpers @@ -51,6 +53,7 @@ public async Task GetsEventsFromStream() event: done data: [DONE] + """; #endregion From 984499d7d4f796550055d0e64127cf08a189bf9b Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Thu, 2 May 2024 16:55:54 -0700 Subject: [PATCH 11/45] Make batch test pass --- ...cServerSentEventJsonDataEnumeratorTests.cs | 42 +++++++++++++++---- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/AsyncServerSentEventJsonDataEnumeratorTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/AsyncServerSentEventJsonDataEnumeratorTests.cs index 660087afcde56..e7796a201048a 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/AsyncServerSentEventJsonDataEnumeratorTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/AsyncServerSentEventJsonDataEnumeratorTests.cs @@ -43,7 +43,7 @@ public async Task EnumeratesBatchEvents() Stream contentStream = BinaryData.FromString(_mockBatchEventContent).ToStream(); using ServerSentEventReader reader = new(contentStream); using AsyncServerSentEventEnumerator eventEnumerator = new(reader); - using AsyncServerSentEventJsonDataEnumerator modelEnumerator = new(eventEnumerator); + using AsyncServerSentEventJsonDataEnumerator modelEnumerator = new(eventEnumerator); int i = 0; while (await modelEnumerator.MoveNextAsync()) @@ -82,13 +82,13 @@ public async Task EnumeratesBatchEvents() private string _mockBatchEventContent = """ event: event.0 - data: { { "IntValue": 0, "StringValue": "0" }, { "IntValue": 1, "StringValue": "1" } } + data: { "values" : [ { "IntValue": 0, "StringValue": "0" }, { "IntValue": 1, "StringValue": "1" } ] } event: event.1 - data: { "IntValue": 2, "StringValue": "2" } + data: { "values" : [ { "IntValue": 2, "StringValue": "2" } ] } event: event.2 - data: { { "IntValue": 3, "StringValue": "3" }, { "IntValue": 4, "StringValue": "4" }, { "IntValue": 5, "StringValue": "5" } } + data: { "values": [ { "IntValue": 3, "StringValue": "3" }, { "IntValue": 4, "StringValue": "4" }, { "IntValue": 5, "StringValue": "5" } ]} event: done data: [DONE] @@ -97,24 +97,48 @@ public async Task EnumeratesBatchEvents() private class MockJsonModelCollection : ReadOnlyCollection, IJsonModel { + public MockJsonModelCollection() : base(new List()) + { + } + public MockJsonModelCollection(IList models) : base(models) { } MockJsonModelCollection IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) { - throw new NotImplementedException(); + using JsonDocument document = JsonDocument.ParseValue(ref reader); + + List list = new(); + foreach (JsonProperty property in document.RootElement.EnumerateObject()) + { + if (property.NameEquals("values"u8)) + { + foreach (JsonElement value in property.Value.EnumerateArray()) + { + BinaryData data = BinaryData.FromString(value.ToString()); + MockJsonModel model = ModelReaderWriter.Read(data)!; + list.Add(model); + } + } + } + + return new MockJsonModelCollection(list); } MockJsonModelCollection IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) { - throw new NotImplementedException(); + if (options?.Format != "J") + { + throw new InvalidOperationException(); + } + + Utf8JsonReader reader = new(data); + return ((IJsonModel)this).Create(ref reader, options); } string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) - { - throw new NotImplementedException(); - } + => "J"; void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) { From f26a89b62f172df31d946dfbf99808861e9f840e Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Thu, 2 May 2024 17:31:51 -0700 Subject: [PATCH 12/45] remove collection-event functionality and add tests for public type --- .../AsyncServerSentEventJsonDataEnumerator.cs | 35 ++---- .../src/Internal/SSE/StreamingClientResult.cs | 16 +-- .../ClientResultCollectionTests.cs | 41 ++++++- ...cServerSentEventJsonDataEnumeratorTests.cs | 101 +----------------- .../Convenience/StreamingClientResultTests.cs | 54 ++++++++++ 5 files changed, 102 insertions(+), 145 deletions(-) create mode 100644 sdk/core/System.ClientModel/tests/internal/Convenience/StreamingClientResultTests.cs diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventJsonDataEnumerator.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventJsonDataEnumerator.cs index 45bfb559bf61e..3dcac7ecec039 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventJsonDataEnumerator.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventJsonDataEnumerator.cs @@ -8,23 +8,15 @@ namespace System.ClientModel.Internal; -internal class AsyncServerSentEventJsonDataEnumerator : AsyncServerSentEventJsonDataEnumerator +internal class AsyncServerSentEventJsonDataEnumerator : IAsyncEnumerator, IDisposable, IAsyncDisposable where T : IJsonModel -{ - public AsyncServerSentEventJsonDataEnumerator(AsyncServerSentEventEnumerator eventEnumerator) : base(eventEnumerator) - { } -} - -internal class AsyncServerSentEventJsonDataEnumerator : IAsyncEnumerator, IDisposable, IAsyncDisposable - where TJsonDataType : IJsonModel { private readonly AsyncServerSentEventEnumerator _eventEnumerator; - private IEnumerator? _currentInstanceEnumerator; - private TInstanceType? _current; + private T? _current; // TODO: is null supression the correct pattern here? - public TInstanceType Current { get => _current!; } + public T Current { get => _current!; } public AsyncServerSentEventJsonDataEnumerator(AsyncServerSentEventEnumerator eventEnumerator) { @@ -35,39 +27,24 @@ public AsyncServerSentEventJsonDataEnumerator(AsyncServerSentEventEnumerator eve public async ValueTask MoveNextAsync() { - if (_currentInstanceEnumerator?.MoveNext() == true) - { - _current = _currentInstanceEnumerator.Current; - return true; - } - if (await _eventEnumerator.MoveNextAsync().ConfigureAwait(false)) { using JsonDocument eventDocument = JsonDocument.Parse(_eventEnumerator.Current.Data); BinaryData eventData = BinaryData.FromObjectAsJson(eventDocument.RootElement); - TJsonDataType? jsonData = ModelReaderWriter.Read(eventData); + T? jsonData = ModelReaderWriter.Read(eventData); + // TODO: should we stop iterating if we can't deserialize? if (jsonData is null) { _current = default; return false; } - if (jsonData is TInstanceType singleInstanceData) + if (jsonData is T singleInstanceData) { _current = singleInstanceData; return true; } - - if (jsonData is IEnumerable instanceCollectionData) - { - _currentInstanceEnumerator = instanceCollectionData.GetEnumerator(); - if (_currentInstanceEnumerator.MoveNext() == true) - { - _current = _currentInstanceEnumerator.Current; - return true; - } - } } return false; } diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs b/sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs index 49aaf033ddfe6..08787dacc6b07 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs @@ -51,22 +51,8 @@ public static StreamingClientResult Create(PipelineResponse response, Canc return new(response, GetServerSentEventDeserializationEnumerator); } - public static StreamingClientResult Create(PipelineResponse response, CancellationToken cancellationToken = default) - where TJsonDataType : IJsonModel - { - return new(response, GetServerSentEventDeserializationEnumerator); - } - private static IAsyncEnumerator GetServerSentEventDeserializationEnumerator(Stream stream, CancellationToken cancellationToken = default) where U : IJsonModel - { - return GetServerSentEventDeserializationEnumerator(stream, cancellationToken); - } - - private static IAsyncEnumerator GetServerSentEventDeserializationEnumerator( - Stream stream, - CancellationToken cancellationToken = default) - where TJsonDataType : IJsonModel { ServerSentEventReader? sseReader = null; AsyncServerSentEventEnumerator? sseEnumerator = null; @@ -74,7 +60,7 @@ private static IAsyncEnumerator GetServerSentEventDeserialization { sseReader = new(stream); sseEnumerator = new(sseReader, cancellationToken); - AsyncServerSentEventJsonDataEnumerator instanceEnumerator = new(sseEnumerator); + AsyncServerSentEventJsonDataEnumerator instanceEnumerator = new(sseEnumerator); sseEnumerator = null; sseReader = null; return instanceEnumerator; diff --git a/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs b/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs index 21468bfa6db4a..fc35af69b0d55 100644 --- a/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs +++ b/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs @@ -21,8 +21,7 @@ public async Task CreatesAsyncResultCollection() MockPipelineResponse response = new(); response.SetContent("[DONE]"); - AsyncClientResultCollection results = - AsyncClientResultCollection.Create(response); + var results = AsyncClientResultCollection.Create(response); bool empty = true; await foreach (MockJsonModel result in results) @@ -34,4 +33,42 @@ public async Task CreatesAsyncResultCollection() Assert.AreEqual(results.GetRawResponse(), response); Assert.IsTrue(empty); } + + [Test] + public async Task EnumeratesModelValues() + { + MockPipelineResponse response = new(); + response.SetContent(_mockContent); + var results = AsyncClientResultCollection.Create(response); + + int i = 0; + await foreach (MockJsonModel model in results) + { + Assert.AreEqual(model.IntValue, i); + Assert.AreEqual(model.StringValue, i.ToString()); + + i++; + } + + Assert.AreEqual(i, 3); + } + + #region Helpers + + private readonly string _mockContent = """ + event: event.0 + data: { "IntValue": 0, "StringValue": "0" } + + event: event.1 + data: { "IntValue": 1, "StringValue": "1" } + + event: event.2 + data: { "IntValue": 2, "StringValue": "2" } + + event: done + data: [DONE] + + """; + + #endregion } diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/AsyncServerSentEventJsonDataEnumeratorTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/AsyncServerSentEventJsonDataEnumeratorTests.cs index e7796a201048a..4cbccc0c32deb 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/AsyncServerSentEventJsonDataEnumeratorTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/AsyncServerSentEventJsonDataEnumeratorTests.cs @@ -2,11 +2,7 @@ // Licensed under the MIT License. using System.ClientModel.Internal; -using System.ClientModel.Primitives; -using System.Collections.Generic; -using System.Collections.ObjectModel; using System.IO; -using System.Text.Json; using System.Threading.Tasks; using Azure.Core.TestFramework; using NUnit.Framework; @@ -18,7 +14,7 @@ public class AsyncServerSentEventJsonDataEnumeratorTests [Test] public async Task EnumeratesSingleEvents() { - Stream contentStream = BinaryData.FromString(_mockSingleEventContent).ToStream(); + Stream contentStream = BinaryData.FromString(_mockContent).ToStream(); using ServerSentEventReader reader = new(contentStream); using AsyncServerSentEventEnumerator eventEnumerator = new(reader); using AsyncServerSentEventJsonDataEnumerator modelEnumerator = new(eventEnumerator); @@ -37,35 +33,13 @@ public async Task EnumeratesSingleEvents() Assert.AreEqual(i, 3); } - [Test] - public async Task EnumeratesBatchEvents() - { - Stream contentStream = BinaryData.FromString(_mockBatchEventContent).ToStream(); - using ServerSentEventReader reader = new(contentStream); - using AsyncServerSentEventEnumerator eventEnumerator = new(reader); - using AsyncServerSentEventJsonDataEnumerator modelEnumerator = new(eventEnumerator); - - int i = 0; - while (await modelEnumerator.MoveNextAsync()) - { - MockJsonModel model = modelEnumerator.Current; - - Assert.AreEqual(model.IntValue, i); - Assert.AreEqual(model.StringValue, i.ToString()); - - i++; - } - - Assert.AreEqual(i, 6); - } - // TODO: Add tests for dispose and handling cancellation token // TODO: later, add tests for varying the _doneToken value. // TODO: tests for infinite stream -- no terminal event; how to show it won't stop? #region Helpers - private string _mockSingleEventContent = """ + private readonly string _mockContent = """ event: event.0 data: { "IntValue": 0, "StringValue": "0" } @@ -80,76 +54,5 @@ public async Task EnumeratesBatchEvents() """; - private string _mockBatchEventContent = """ - event: event.0 - data: { "values" : [ { "IntValue": 0, "StringValue": "0" }, { "IntValue": 1, "StringValue": "1" } ] } - - event: event.1 - data: { "values" : [ { "IntValue": 2, "StringValue": "2" } ] } - - event: event.2 - data: { "values": [ { "IntValue": 3, "StringValue": "3" }, { "IntValue": 4, "StringValue": "4" }, { "IntValue": 5, "StringValue": "5" } ]} - - event: done - data: [DONE] - - """; - - private class MockJsonModelCollection : ReadOnlyCollection, IJsonModel - { - public MockJsonModelCollection() : base(new List()) - { - } - - public MockJsonModelCollection(IList models) : base(models) - { - } - - MockJsonModelCollection IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) - { - using JsonDocument document = JsonDocument.ParseValue(ref reader); - - List list = new(); - foreach (JsonProperty property in document.RootElement.EnumerateObject()) - { - if (property.NameEquals("values"u8)) - { - foreach (JsonElement value in property.Value.EnumerateArray()) - { - BinaryData data = BinaryData.FromString(value.ToString()); - MockJsonModel model = ModelReaderWriter.Read(data)!; - list.Add(model); - } - } - } - - return new MockJsonModelCollection(list); - } - - MockJsonModelCollection IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) - { - if (options?.Format != "J") - { - throw new InvalidOperationException(); - } - - Utf8JsonReader reader = new(data); - return ((IJsonModel)this).Create(ref reader, options); - } - - string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) - => "J"; - - void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) - { - throw new NotImplementedException(); - } - - BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) - { - throw new NotImplementedException(); - } - } - #endregion } diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/StreamingClientResultTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/StreamingClientResultTests.cs new file mode 100644 index 0000000000000..1b3cff691faa0 --- /dev/null +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/StreamingClientResultTests.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ClientModel.Internal; +using System.Threading.Tasks; +using Azure.Core.TestFramework; +using ClientModel.Tests.Mocks; +using NUnit.Framework; + +namespace System.ClientModel.Tests.Convenience; + +public class StreamingClientResultTests +{ + [Test] + public async Task EnumeratesModelValues() + { + MockPipelineResponse response = new(); + response.SetContent(_mockContent); + var results = StreamingClientResult.Create(response); + + int i = 0; + await foreach (MockJsonModel model in results) + { + Assert.AreEqual(model.IntValue, i); + Assert.AreEqual(model.StringValue, i.ToString()); + + i++; + } + + Assert.AreEqual(i, 3); + } + + // TODO: Add tests for dispose and handling cancellation token + // TODO: later, add tests for varying the _doneToken value. + + #region Helpers + + private readonly string _mockContent = """ + event: event.0 + data: { "IntValue": 0, "StringValue": "0" } + + event: event.1 + data: { "IntValue": 1, "StringValue": "1" } + + event: event.2 + data: { "IntValue": 2, "StringValue": "2" } + + event: done + data: [DONE] + + """; + + #endregion +} From 647f1a9f4a5f098f15e592565c3beef785987f77 Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Thu, 2 May 2024 17:39:08 -0700 Subject: [PATCH 13/45] reshuffle --- .../tests/Convenience/ClientResultTests.cs | 45 ++++++++++++++ .../tests/TestFramework/Mocks/MockClient.cs | 58 ------------------- .../AsyncServerSentEventEnumeratorTests.cs | 0 ...cServerSentEventJsonDataEnumeratorTests.cs | 0 .../{ => SSE}/ServerSentEventFieldTests.cs | 0 .../{ => SSE}/ServerSentEventReaderTests.cs | 0 .../{ => SSE}/ServerSentEventTests.cs | 0 .../{ => SSE}/StreamingClientResultTests.cs | 0 8 files changed, 45 insertions(+), 58 deletions(-) delete mode 100644 sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs rename sdk/core/System.ClientModel/tests/internal/Convenience/{ => SSE}/AsyncServerSentEventEnumeratorTests.cs (100%) rename sdk/core/System.ClientModel/tests/internal/Convenience/{ => SSE}/AsyncServerSentEventJsonDataEnumeratorTests.cs (100%) rename sdk/core/System.ClientModel/tests/internal/Convenience/{ => SSE}/ServerSentEventFieldTests.cs (100%) rename sdk/core/System.ClientModel/tests/internal/Convenience/{ => SSE}/ServerSentEventReaderTests.cs (100%) rename sdk/core/System.ClientModel/tests/internal/Convenience/{ => SSE}/ServerSentEventTests.cs (100%) rename sdk/core/System.ClientModel/tests/internal/Convenience/{ => SSE}/StreamingClientResultTests.cs (100%) diff --git a/sdk/core/System.ClientModel/tests/Convenience/ClientResultTests.cs b/sdk/core/System.ClientModel/tests/Convenience/ClientResultTests.cs index 381404a0399fb..629f1ec27f2b1 100644 --- a/sdk/core/System.ClientModel/tests/Convenience/ClientResultTests.cs +++ b/sdk/core/System.ClientModel/tests/Convenience/ClientResultTests.cs @@ -257,6 +257,51 @@ public DerivedClientResult(T value, PipelineResponse response) : base(value, res } } + internal class MockClient + { + public virtual ClientResult GetModel(int intValue, string stringValue) + { + MockPipelineResponse response = new(200); + MockJsonModel model = new MockJsonModel(intValue, stringValue); + return ClientResult.FromValue(model, response); + } + + public virtual ClientResult GetOptionalModel(int intValue, string stringValue, bool hasValue) + { + if (hasValue) + { + MockPipelineResponse response = new(200); + MockJsonModel model = new MockJsonModel(intValue, stringValue); + return ClientResult.FromOptionalValue(model, response); + } + else + { + MockPipelineResponse response = new(404); + return ClientResult.FromOptionalValue(default, response); + } + } + + public virtual ClientResult GetCount(int count) + { + MockPipelineResponse response = new(200); + return ClientResult.FromValue(count, response); + } + + public virtual ClientResult GetOptionalCount(int count, bool hasValue) + { + if (hasValue) + { + MockPipelineResponse response = new(200); + return ClientResult.FromOptionalValue(count, response); + } + else + { + MockPipelineResponse response = new(404); + return ClientResult.FromOptionalValue(default, response); + } + } + } + internal class CastableClientResult : ClientResult { protected internal CastableClientResult(T value, PipelineResponse response) : base(value, response) diff --git a/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs b/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs deleted file mode 100644 index 20e25ad2da12d..0000000000000 --- a/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System.ClientModel; -using System.Collections.Generic; -using Azure.Core.TestFramework; - -namespace ClientModel.Tests.Mocks; - -internal class MockClient -{ - public virtual ClientResult GetModel(int intValue, string stringValue) - { - MockPipelineResponse response = new(200); - MockJsonModel model = new MockJsonModel(intValue, stringValue); - return ClientResult.FromValue(model, response); - } - - public virtual ClientResult GetOptionalModel(int intValue, string stringValue, bool hasValue) - { - if (hasValue) - { - MockPipelineResponse response = new(200); - MockJsonModel model = new MockJsonModel(intValue, stringValue); - return ClientResult.FromOptionalValue(model, response); - } - else - { - MockPipelineResponse response = new(404); - return ClientResult.FromOptionalValue(default, response); - } - } - - public virtual ClientResult GetCount(int count) - { - MockPipelineResponse response = new(200); - return ClientResult.FromValue(count, response); - } - - public virtual ClientResult GetOptionalCount(int count, bool hasValue) - { - if (hasValue) - { - MockPipelineResponse response = new(200); - return ClientResult.FromOptionalValue(count, response); - } - else - { - MockPipelineResponse response = new(404); - return ClientResult.FromOptionalValue(default, response); - } - } - - //public virtual AsyncClientResultCollection GetSseModels(IEnumerable models) - //{ - - //} -} diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/AsyncServerSentEventEnumeratorTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumeratorTests.cs similarity index 100% rename from sdk/core/System.ClientModel/tests/internal/Convenience/AsyncServerSentEventEnumeratorTests.cs rename to sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumeratorTests.cs diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/AsyncServerSentEventJsonDataEnumeratorTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventJsonDataEnumeratorTests.cs similarity index 100% rename from sdk/core/System.ClientModel/tests/internal/Convenience/AsyncServerSentEventJsonDataEnumeratorTests.cs rename to sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventJsonDataEnumeratorTests.cs diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventFieldTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventFieldTests.cs similarity index 100% rename from sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventFieldTests.cs rename to sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventFieldTests.cs diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventReaderTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs similarity index 100% rename from sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventReaderTests.cs rename to sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventTests.cs similarity index 100% rename from sdk/core/System.ClientModel/tests/internal/Convenience/ServerSentEventTests.cs rename to sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventTests.cs diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/StreamingClientResultTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/StreamingClientResultTests.cs similarity index 100% rename from sdk/core/System.ClientModel/tests/internal/Convenience/StreamingClientResultTests.cs rename to sdk/core/System.ClientModel/tests/internal/Convenience/SSE/StreamingClientResultTests.cs From 3189fb35f153d2ca5f0c70793b8107c2d22c1eb7 Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Fri, 3 May 2024 14:17:04 -0700 Subject: [PATCH 14/45] Add mock convenience SSE type to give POC of lazy request sending --- .../AsyncClientResultCollectionOfT.cs | 27 ------ .../Convenience/AsyncResultCollectionOfT.cs | 39 ++++++++ .../src/Convenience/ClientResult.cs | 2 +- .../src/Internal/SSE/StreamingClientResult.cs | 2 +- .../ClientResultCollectionTests.cs | 4 +- .../tests/Convenience/ClientResultTests.cs | 45 --------- .../tests/TestFramework/Mocks/MockClient.cs | 93 +++++++++++++++++++ 7 files changed, 136 insertions(+), 76 deletions(-) delete mode 100644 sdk/core/System.ClientModel/src/Convenience/AsyncClientResultCollectionOfT.cs create mode 100644 sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs create mode 100644 sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs diff --git a/sdk/core/System.ClientModel/src/Convenience/AsyncClientResultCollectionOfT.cs b/sdk/core/System.ClientModel/src/Convenience/AsyncClientResultCollectionOfT.cs deleted file mode 100644 index 53845f3b9898e..0000000000000 --- a/sdk/core/System.ClientModel/src/Convenience/AsyncClientResultCollectionOfT.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System.ClientModel.Internal; -using System.ClientModel.Primitives; -using System.Collections.Generic; -using System.Threading; - -namespace System.ClientModel; - -#pragma warning disable CS1591 // public XML comments -public abstract class AsyncClientResultCollection : ClientResult, IAsyncEnumerable -{ - protected internal AsyncClientResultCollection(PipelineResponse response) : base(response) - { - } - - public abstract IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default); - - // TODO: take CancellationToken? - //public static ClientResultCollection Create(PipelineResponse response) where TValue : IJsonModel - public static AsyncClientResultCollection Create(PipelineResponse response) where TValue : IJsonModel - { - return StreamingClientResult.Create(response); - } -} -#pragma warning restore CS1591 // public XML comments diff --git a/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs b/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs new file mode 100644 index 0000000000000..ef0ac2d79f340 --- /dev/null +++ b/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ClientModel.Internal; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Threading; + +namespace System.ClientModel; + +#pragma warning disable CS1591 // public XML comments +public abstract class AsyncResultCollection : ClientResult, IAsyncEnumerable +{ + // Overload for lazily sending request + protected internal AsyncResultCollection() : base(default) + { + } + + protected internal AsyncResultCollection(PipelineResponse response) : base(response) + { + } + + public abstract IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default); + + // TODO: take CancellationToken -- question -- does the cancellation token go here or to the enumerator? + //public static ClientResultCollection Create(PipelineResponse response) where TValue : IJsonModel + public static AsyncResultCollection Create(PipelineResponse response, CancellationToken cancellationToken = default) + where TValue : IJsonModel + { + return StreamingClientResult.Create(response, cancellationToken); + } + + // TODO: Next - add this! + //public static AsyncResultCollection Create(PipelineResponse response) + //{ + // return StreamingClientResult.Create(response); + //} +} +#pragma warning restore CS1591 // public XML comments diff --git a/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs b/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs index 4943f7e4ade94..6881cea6e15da 100644 --- a/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs +++ b/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs @@ -32,7 +32,7 @@ protected ClientResult(PipelineResponse? response) /// No /// value is currently available for this /// instance. This can happen when the instance - /// is a collection type like + /// is a collection type like /// that has not yet been enumerated. public PipelineResponse GetRawResponse() { diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs b/sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs index 08787dacc6b07..e6fcb801cfc7e 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs @@ -13,7 +13,7 @@ namespace System.ClientModel.Internal; /// is still being received. /// /// The data type representative of distinct, streamable items. -internal class StreamingClientResult : AsyncClientResultCollection +internal class StreamingClientResult : AsyncResultCollection { private readonly Func> _asyncEnumeratorSourceDelegate; diff --git a/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs b/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs index fc35af69b0d55..d1458d4784313 100644 --- a/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs +++ b/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs @@ -21,7 +21,7 @@ public async Task CreatesAsyncResultCollection() MockPipelineResponse response = new(); response.SetContent("[DONE]"); - var results = AsyncClientResultCollection.Create(response); + var results = AsyncResultCollection.Create(response); bool empty = true; await foreach (MockJsonModel result in results) @@ -39,7 +39,7 @@ public async Task EnumeratesModelValues() { MockPipelineResponse response = new(); response.SetContent(_mockContent); - var results = AsyncClientResultCollection.Create(response); + var results = AsyncResultCollection.Create(response); int i = 0; await foreach (MockJsonModel model in results) diff --git a/sdk/core/System.ClientModel/tests/Convenience/ClientResultTests.cs b/sdk/core/System.ClientModel/tests/Convenience/ClientResultTests.cs index 629f1ec27f2b1..381404a0399fb 100644 --- a/sdk/core/System.ClientModel/tests/Convenience/ClientResultTests.cs +++ b/sdk/core/System.ClientModel/tests/Convenience/ClientResultTests.cs @@ -257,51 +257,6 @@ public DerivedClientResult(T value, PipelineResponse response) : base(value, res } } - internal class MockClient - { - public virtual ClientResult GetModel(int intValue, string stringValue) - { - MockPipelineResponse response = new(200); - MockJsonModel model = new MockJsonModel(intValue, stringValue); - return ClientResult.FromValue(model, response); - } - - public virtual ClientResult GetOptionalModel(int intValue, string stringValue, bool hasValue) - { - if (hasValue) - { - MockPipelineResponse response = new(200); - MockJsonModel model = new MockJsonModel(intValue, stringValue); - return ClientResult.FromOptionalValue(model, response); - } - else - { - MockPipelineResponse response = new(404); - return ClientResult.FromOptionalValue(default, response); - } - } - - public virtual ClientResult GetCount(int count) - { - MockPipelineResponse response = new(200); - return ClientResult.FromValue(count, response); - } - - public virtual ClientResult GetOptionalCount(int count, bool hasValue) - { - if (hasValue) - { - MockPipelineResponse response = new(200); - return ClientResult.FromOptionalValue(count, response); - } - else - { - MockPipelineResponse response = new(404); - return ClientResult.FromOptionalValue(default, response); - } - } - } - internal class CastableClientResult : ClientResult { protected internal CastableClientResult(T value, PipelineResponse response) : base(value, response) diff --git a/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs b/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs new file mode 100644 index 0000000000000..a7c6592c1b647 --- /dev/null +++ b/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Threading; +using Azure.Core.TestFramework; + +namespace ClientModel.Tests.Mocks; + +public class MockClient +{ + public virtual ClientResult GetModel(int intValue, string stringValue) + { + MockPipelineResponse response = new(200); + MockJsonModel model = new MockJsonModel(intValue, stringValue); + return ClientResult.FromValue(model, response); + } + + public virtual ClientResult GetOptionalModel(int intValue, string stringValue, bool hasValue) + { + if (hasValue) + { + MockPipelineResponse response = new(200); + MockJsonModel model = new MockJsonModel(intValue, stringValue); + return ClientResult.FromOptionalValue(model, response); + } + else + { + MockPipelineResponse response = new(404); + return ClientResult.FromOptionalValue(default, response); + } + } + + public virtual ClientResult GetCount(int count) + { + MockPipelineResponse response = new(200); + return ClientResult.FromValue(count, response); + } + + public virtual ClientResult GetOptionalCount(int count, bool hasValue) + { + if (hasValue) + { + MockPipelineResponse response = new(200); + return ClientResult.FromOptionalValue(count, response); + } + else + { + MockPipelineResponse response = new(404); + return ClientResult.FromOptionalValue(default, response); + } + } + + // mock convenience method + public virtual AsyncResultCollection GetModelsStreamingAsync(string content) + { + return new MockJsonModelCollection(content, GetModelsStreamingAsync); + } + + // mock protocol method + public virtual ClientResult GetModelsStreamingAsync(string content, RequestOptions? options = default) + { + // This mocks sending a request and returns a respose containing + // the passed-in content in the content stream. + + MockPipelineResponse response = new(); + response.SetContent(content); + return ClientResult.FromResponse(response); + } + + private class MockJsonModelCollection : AsyncResultCollection + { + private readonly string _content; + private readonly Func _protocolMethod; + + public MockJsonModelCollection(string content, Func protocolMethod) + { + _content = content; + _protocolMethod = protocolMethod; + } + + public override IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + ClientResult result = _protocolMethod(_content, /*options:*/ default); + PipelineResponse response = result.GetRawResponse(); + AsyncResultCollection enumerable = Create(response, cancellationToken); + return enumerable.GetAsyncEnumerator(cancellationToken); + } + } +} From 1a0c8616de8488c3b777dd5bac79cc198af41563 Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Fri, 3 May 2024 14:27:10 -0700 Subject: [PATCH 15/45] add tests of delayed request --- .../Convenience/AsyncResultCollectionOfT.cs | 2 +- .../src/Internal/SSE/StreamingClientResult.cs | 2 +- .../ClientResultCollectionTests.cs | 21 +++++++++++++++++++ .../tests/TestFramework/Mocks/MockClient.cs | 5 +++++ 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs b/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs index ef0ac2d79f340..d4542a61cc812 100644 --- a/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs +++ b/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs @@ -27,7 +27,7 @@ protected internal AsyncResultCollection(PipelineResponse response) : base(respo public static AsyncResultCollection Create(PipelineResponse response, CancellationToken cancellationToken = default) where TValue : IJsonModel { - return StreamingClientResult.Create(response, cancellationToken); + return StreamingClientResult.CreateStreaming(response, cancellationToken); } // TODO: Next - add this! diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs b/sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs index e6fcb801cfc7e..c27760a330aa7 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs @@ -45,7 +45,7 @@ private StreamingClientResult(PipelineResponse response, Func /// A new instance of . - public static StreamingClientResult Create(PipelineResponse response, CancellationToken cancellationToken = default) + public static StreamingClientResult CreateStreaming(PipelineResponse response, CancellationToken cancellationToken = default) where U : IJsonModel { return new(response, GetServerSentEventDeserializationEnumerator); diff --git a/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs b/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs index d1458d4784313..914887a00357f 100644 --- a/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs +++ b/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs @@ -53,6 +53,27 @@ public async Task EnumeratesModelValues() Assert.AreEqual(i, 3); } + [Test] + public async Task CanDelaySendingRequest() + { + MockClient client = new MockClient(); + AsyncResultCollection models = client.GetModelsStreamingAsync(_mockContent); + + Assert.IsFalse(client.StreamingProtocolMethodCalled); + + int i = 0; + await foreach (MockJsonModel model in models) + { + Assert.AreEqual(model.IntValue, i); + Assert.AreEqual(model.StringValue, i.ToString()); + + i++; + } + + Assert.AreEqual(i, 3); + Assert.IsTrue(client.StreamingProtocolMethodCalled); + } + #region Helpers private readonly string _mockContent = """ diff --git a/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs b/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs index a7c6592c1b647..049a66957d8a4 100644 --- a/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs +++ b/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs @@ -12,6 +12,8 @@ namespace ClientModel.Tests.Mocks; public class MockClient { + public bool StreamingProtocolMethodCalled { get; private set; } + public virtual ClientResult GetModel(int intValue, string stringValue) { MockPipelineResponse response = new(200); @@ -68,6 +70,9 @@ public virtual ClientResult GetModelsStreamingAsync(string content, RequestOptio MockPipelineResponse response = new(); response.SetContent(content); + + StreamingProtocolMethodCalled = true; + return ClientResult.FromResponse(response); } From d5cbc8ec445f97a735161437fbc94bbb3f341a6a Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Fri, 3 May 2024 15:24:14 -0700 Subject: [PATCH 16/45] Add BinaryData factory method --- .../Convenience/AsyncResultCollectionOfT.cs | 10 +++---- .../src/Internal/SSE/ServerSentEventReader.cs | 16 +++++++++-- .../src/Internal/SSE/StreamingClientResult.cs | 27 +++++++++++++++++-- .../ClientResultCollectionTests.cs | 23 +++++++++++++++- 4 files changed, 66 insertions(+), 10 deletions(-) diff --git a/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs b/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs index d4542a61cc812..be9fa083401ca 100644 --- a/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs +++ b/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs @@ -24,16 +24,16 @@ protected internal AsyncResultCollection(PipelineResponse response) : base(respo // TODO: take CancellationToken -- question -- does the cancellation token go here or to the enumerator? //public static ClientResultCollection Create(PipelineResponse response) where TValue : IJsonModel + // TODO: terminal event can be a model type as well ... are we happy using string for now and adding an overload if needed later? public static AsyncResultCollection Create(PipelineResponse response, CancellationToken cancellationToken = default) where TValue : IJsonModel { return StreamingClientResult.CreateStreaming(response, cancellationToken); } - // TODO: Next - add this! - //public static AsyncResultCollection Create(PipelineResponse response) - //{ - // return StreamingClientResult.Create(response); - //} + public static AsyncResultCollection Create(PipelineResponse response, CancellationToken cancellationToken = default) + { + return StreamingClientResult.CreateStreaming(response, cancellationToken); + } } #pragma warning restore CS1591 // public XML comments diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs index b423a3747a7f1..b0b68c6e276eb 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs @@ -8,7 +8,8 @@ namespace System.ClientModel.Internal; -internal sealed class ServerSentEventReader : IDisposable +// TODO: Different sync and async readers to dispose differently? +internal sealed class ServerSentEventReader : IDisposable, IAsyncDisposable { private readonly Stream _stream; private readonly StreamReader _reader; @@ -28,7 +29,6 @@ public ServerSentEventReader(Stream stream) /// /// The next in the stream, or null once no more data can be read from the stream. /// - // TODO: Would we rather use standard .NET TryGet semantics? public ServerSentEvent? TryGetNextEvent(CancellationToken cancellationToken = default) { List fields = new(); @@ -123,4 +123,16 @@ private void Dispose(bool disposing) _disposedValue = true; } } + + // TODO: get this pattern right + public async ValueTask DisposeAsync() + { +#if NETSTANDARD2_0 + // TODO: is this the right pattern for calling sync methods in + // async contexts? + await Task.Run(_stream.Dispose).ConfigureAwait(false); +#else + await _stream.DisposeAsync().ConfigureAwait(false); +#endif + } } diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs b/sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs index c27760a330aa7..550b055812362 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs @@ -4,6 +4,7 @@ using System.ClientModel.Primitives; using System.Collections.Generic; using System.IO; +using System.Text; using System.Threading; namespace System.ClientModel.Internal; @@ -17,8 +18,9 @@ internal class StreamingClientResult : AsyncResultCollection { private readonly Func> _asyncEnumeratorSourceDelegate; - // TODO: use? - //private bool _disposedValue; + // TODO: Add an overload that creates the type but delays sending the response + // for use with convenience methods. Do we need to take a func in the public + // method or is there another way? private StreamingClientResult(PipelineResponse response, Func> asyncEnumeratorSourceDelegate) : base(response) @@ -51,6 +53,26 @@ public static StreamingClientResult CreateStreaming(PipelineResponse respo return new(response, GetServerSentEventDeserializationEnumerator); } + public static StreamingClientResult CreateStreaming(PipelineResponse response, CancellationToken cancellationToken = default) + { + return new(response, GetBinaryDataEnumerator); + } + + private static async IAsyncEnumerator GetBinaryDataEnumerator(Stream stream, CancellationToken cancellationToken = default) + { + // TODO: handle via DisposeAsync instead? + + using ServerSentEventReader reader = new(stream); + using AsyncServerSentEventEnumerator sseEnumerator = new(reader, cancellationToken); + while (await sseEnumerator.MoveNextAsync().ConfigureAwait(false)) + { + // TODO: more efficient way? + char[] chars = sseEnumerator.Current.Data.ToArray(); + byte[] bytes = Encoding.UTF8.GetBytes(chars); + yield return new BinaryData(bytes); + } + } + private static IAsyncEnumerator GetServerSentEventDeserializationEnumerator(Stream stream, CancellationToken cancellationToken = default) where U : IJsonModel { @@ -67,6 +89,7 @@ private static IAsyncEnumerator GetServerSentEventDeserializationEnumerator results = AsyncResultCollection.Create(response); + + int i = 0; + await foreach (BinaryData value in results) + { + MockJsonModel model = value.ToObjectFromJson(); + + Assert.AreEqual(model.IntValue, i); + Assert.AreEqual(model.StringValue, i.ToString()); + + i++; + } + + Assert.AreEqual(i, 3); + } + [Test] public async Task CanDelaySendingRequest() { From 0beaeea7bfc56e373fb52a8031f4489e7e14b082 Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Mon, 6 May 2024 10:08:26 -0700 Subject: [PATCH 17/45] remove funcs for creating enumerators --- .../src/MockJsonModel.cs | 2 +- .../api/System.ClientModel.net6.0.cs | 8 +- .../api/System.ClientModel.netstandard2.0.cs | 8 +- .../Convenience/AsyncResultCollectionOfT.cs | 20 +++- .../src/Convenience/ClientResult.cs | 2 + .../AsyncServerSentEventJsonDataEnumerator.cs | 61 ----------- .../Internal/SSE/AsyncSseDataEnumerable.cs | 78 ++++++++++++++ .../Internal/SSE/AsyncSseValueEnumerable.cs | 86 +++++++++++++++ .../src/Internal/SSE/StreamingClientResult.cs | 102 ------------------ ...cServerSentEventJsonDataEnumeratorTests.cs | 15 ++- .../SSE/StreamingClientResultTests.cs | 76 ++++++------- 11 files changed, 240 insertions(+), 218 deletions(-) delete mode 100644 sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventJsonDataEnumerator.cs create mode 100644 sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseDataEnumerable.cs create mode 100644 sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseValueEnumerable.cs delete mode 100644 sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs diff --git a/sdk/core/Azure.Core.TestFramework/src/MockJsonModel.cs b/sdk/core/Azure.Core.TestFramework/src/MockJsonModel.cs index 2b2774b4b8efc..d683d195f0337 100644 --- a/sdk/core/Azure.Core.TestFramework/src/MockJsonModel.cs +++ b/sdk/core/Azure.Core.TestFramework/src/MockJsonModel.cs @@ -11,7 +11,7 @@ namespace Azure.Core.TestFramework { public class MockJsonModel : IJsonModel { - internal MockJsonModel() + public MockJsonModel() { } diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs index 38f6b92d7e3a2..c7da86f9b411c 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs @@ -7,10 +7,12 @@ public ApiKeyCredential(string key) { } public static implicit operator System.ClientModel.ApiKeyCredential (string key) { throw null; } public void Update(string key) { } } - public abstract partial class AsyncClientResultCollection : System.ClientModel.ClientResult, System.Collections.Generic.IAsyncEnumerable + public abstract partial class AsyncResultCollection : System.ClientModel.ClientResult, System.Collections.Generic.IAsyncEnumerable { - protected internal AsyncClientResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } - public static System.ClientModel.AsyncClientResultCollection Create(System.ClientModel.Primitives.PipelineResponse response) where TValue : System.ClientModel.Primitives.IJsonModel { throw null; } + protected internal AsyncResultCollection() : base (default(System.ClientModel.Primitives.PipelineResponse)) { } + protected internal AsyncResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } + public static System.ClientModel.AsyncResultCollection Create(System.ClientModel.Primitives.PipelineResponse response, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.ClientModel.AsyncResultCollection Create(System.ClientModel.Primitives.PipelineResponse response, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) where TValue : System.ClientModel.Primitives.IJsonModel { throw null; } public abstract System.Collections.Generic.IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); } public abstract partial class BinaryContent : System.IDisposable diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs index 8b08908c85907..0da0786daa3da 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs @@ -7,10 +7,12 @@ public ApiKeyCredential(string key) { } public static implicit operator System.ClientModel.ApiKeyCredential (string key) { throw null; } public void Update(string key) { } } - public abstract partial class AsyncClientResultCollection : System.ClientModel.ClientResult, System.Collections.Generic.IAsyncEnumerable + public abstract partial class AsyncResultCollection : System.ClientModel.ClientResult, System.Collections.Generic.IAsyncEnumerable { - protected internal AsyncClientResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } - public static System.ClientModel.AsyncClientResultCollection Create(System.ClientModel.Primitives.PipelineResponse response) where TValue : System.ClientModel.Primitives.IJsonModel { throw null; } + protected internal AsyncResultCollection() : base (default(System.ClientModel.Primitives.PipelineResponse)) { } + protected internal AsyncResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } + public static System.ClientModel.AsyncResultCollection Create(System.ClientModel.Primitives.PipelineResponse response, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.ClientModel.AsyncResultCollection Create(System.ClientModel.Primitives.PipelineResponse response, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) where TValue : System.ClientModel.Primitives.IJsonModel { throw null; } public abstract System.Collections.Generic.IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); } public abstract partial class BinaryContent : System.IDisposable diff --git a/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs b/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs index be9fa083401ca..53230b0b11990 100644 --- a/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs +++ b/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs @@ -28,12 +28,28 @@ protected internal AsyncResultCollection(PipelineResponse response) : base(respo public static AsyncResultCollection Create(PipelineResponse response, CancellationToken cancellationToken = default) where TValue : IJsonModel { - return StreamingClientResult.CreateStreaming(response, cancellationToken); + Argument.AssertNotNull(response, nameof(response)); + + if (response.ContentStream is null) + { + throw new ArgumentException("Unable to create result from response with null ContentStream", nameof(response)); + } + + // TODO: correct pattern for cancellation token + return new AsyncSseValueResultCollection(response); } public static AsyncResultCollection Create(PipelineResponse response, CancellationToken cancellationToken = default) { - return StreamingClientResult.CreateStreaming(response, cancellationToken); + Argument.AssertNotNull(response, nameof(response)); + + if (response.ContentStream is null) + { + throw new ArgumentException("Unable to create result from response with null ContentStream", nameof(response)); + } + + // TODO: correct pattern for cancellation token + return new AsyncSseDataResultCollection(response); } } #pragma warning restore CS1591 // public XML comments diff --git a/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs b/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs index 6881cea6e15da..7366785117eb7 100644 --- a/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs +++ b/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs @@ -58,6 +58,8 @@ public PipelineResponse GetRawResponse() /// from . protected void SetRawResponse(PipelineResponse response) { + Argument.AssertNotNull(response, nameof(response)); + _response = response; } diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventJsonDataEnumerator.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventJsonDataEnumerator.cs deleted file mode 100644 index 3dcac7ecec039..0000000000000 --- a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventJsonDataEnumerator.cs +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System.ClientModel.Primitives; -using System.Collections.Generic; -using System.Text.Json; -using System.Threading.Tasks; - -namespace System.ClientModel.Internal; - -internal class AsyncServerSentEventJsonDataEnumerator : IAsyncEnumerator, IDisposable, IAsyncDisposable - where T : IJsonModel -{ - private readonly AsyncServerSentEventEnumerator _eventEnumerator; - - private T? _current; - - // TODO: is null supression the correct pattern here? - public T Current { get => _current!; } - - public AsyncServerSentEventJsonDataEnumerator(AsyncServerSentEventEnumerator eventEnumerator) - { - Argument.AssertNotNull(eventEnumerator, nameof(eventEnumerator)); - - _eventEnumerator = eventEnumerator; - } - - public async ValueTask MoveNextAsync() - { - if (await _eventEnumerator.MoveNextAsync().ConfigureAwait(false)) - { - using JsonDocument eventDocument = JsonDocument.Parse(_eventEnumerator.Current.Data); - BinaryData eventData = BinaryData.FromObjectAsJson(eventDocument.RootElement); - T? jsonData = ModelReaderWriter.Read(eventData); - - // TODO: should we stop iterating if we can't deserialize? - if (jsonData is null) - { - _current = default; - return false; - } - - if (jsonData is T singleInstanceData) - { - _current = singleInstanceData; - return true; - } - } - return false; - } - - public async ValueTask DisposeAsync() - { - await _eventEnumerator.DisposeAsync().ConfigureAwait(false); - } - - public void Dispose() - { - _eventEnumerator.Dispose(); - } -} diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseDataEnumerable.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseDataEnumerable.cs new file mode 100644 index 0000000000000..0ba11bfd37d49 --- /dev/null +++ b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseDataEnumerable.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace System.ClientModel.Internal; + +internal class AsyncSseDataResultCollection : AsyncResultCollection +{ + // Note: this one doesn't delay sending the request because it's used + // with protocol methods. + public AsyncSseDataResultCollection(PipelineResponse response) : base(response) + { + Argument.AssertNotNull(response, nameof(response)); + } + + public override IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + PipelineResponse response = GetRawResponse(); + + // We validate that response.ContentStream is non-null in + // AsyncResultCollection.Create method. + Debug.Assert(response.ContentStream is not null); + + ServerSentEventReader reader = new(response.ContentStream!); + AsyncServerSentEventEnumerator sseEnumerator = new(reader, cancellationToken); + return new AsyncSseDataEnumerator(sseEnumerator); + } + + // TODO: probably change the name back to AsyncSseBinaryDataEnumerator. + // Right now, I want to reason about it as "the thing that enumerates data elements as BinaryData." + private class AsyncSseDataEnumerator : IAsyncEnumerator, IDisposable, IAsyncDisposable + { + private readonly AsyncServerSentEventEnumerator _eventEnumerator; + + private BinaryData? _current; + + // TODO: is null supression the correct pattern here? + public BinaryData Current { get => _current!; } + + public AsyncSseDataEnumerator(AsyncServerSentEventEnumerator eventEnumerator) + { + Argument.AssertNotNull(eventEnumerator, nameof(eventEnumerator)); + + _eventEnumerator = eventEnumerator; + } + + public async ValueTask MoveNextAsync() + { + if (await _eventEnumerator.MoveNextAsync().ConfigureAwait(false)) + { + char[] chars = _eventEnumerator.Current.Data.ToArray(); + byte[] bytes = Encoding.UTF8.GetBytes(chars); + _current = new BinaryData(bytes); + return true; + } + + _current = null; + return false; + } + + // TODO: implement this pattern correctly. + public async ValueTask DisposeAsync() + { + await _eventEnumerator.DisposeAsync().ConfigureAwait(false); + } + + public void Dispose() + { + _eventEnumerator.Dispose(); + } + } +} diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseValueEnumerable.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseValueEnumerable.cs new file mode 100644 index 0000000000000..34f7a37cbf47a --- /dev/null +++ b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseValueEnumerable.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace System.ClientModel.Internal; + +internal class AsyncSseValueResultCollection : AsyncResultCollection + where T : IJsonModel +{ + // TODO: delay sending request + public AsyncSseValueResultCollection(PipelineResponse response) : base(response) + { + } + + public override IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + PipelineResponse response = GetRawResponse(); + + // We validate that response.ContentStream is non-null in + // AsyncResultCollection.Create method. + Debug.Assert(response.ContentStream is not null); + + ServerSentEventReader reader = new(response.ContentStream!); + AsyncServerSentEventEnumerator sseEnumerator = new(reader, cancellationToken); + return new AsyncSseValueEnumerator(sseEnumerator); + } + + // TODO: probably change the name back to AsyncSseJsonModelEnumerator. + // Right now, I want to reason about it as "the thing that enumerates values." + private class AsyncSseValueEnumerator : IAsyncEnumerator, IDisposable, IAsyncDisposable + { + private readonly AsyncServerSentEventEnumerator _eventEnumerator; + + private T? _current; + + // TODO: is null supression the correct pattern here? + public T Current { get => _current!; } + + public AsyncSseValueEnumerator(AsyncServerSentEventEnumerator eventEnumerator) + { + Argument.AssertNotNull(eventEnumerator, nameof(eventEnumerator)); + + _eventEnumerator = eventEnumerator; + } + + public async ValueTask MoveNextAsync() + { + if (await _eventEnumerator.MoveNextAsync().ConfigureAwait(false)) + { + using JsonDocument eventDocument = JsonDocument.Parse(_eventEnumerator.Current.Data); + BinaryData eventData = BinaryData.FromObjectAsJson(eventDocument.RootElement); + T? jsonData = ModelReaderWriter.Read(eventData); + + // TODO: should we stop iterating if we can't deserialize? + if (jsonData is null) + { + _current = default; + return false; + } + + if (jsonData is T singleInstanceData) + { + _current = singleInstanceData; + return true; + } + } + return false; + } + + public async ValueTask DisposeAsync() + { + await _eventEnumerator.DisposeAsync().ConfigureAwait(false); + } + + public void Dispose() + { + _eventEnumerator.Dispose(); + } + } +} diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs b/sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs deleted file mode 100644 index 550b055812362..0000000000000 --- a/sdk/core/System.ClientModel/src/Internal/SSE/StreamingClientResult.cs +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System.ClientModel.Primitives; -using System.Collections.Generic; -using System.IO; -using System.Text; -using System.Threading; - -namespace System.ClientModel.Internal; - -/// -/// Represents an operation response with streaming content that can be deserialized and enumerated while the response -/// is still being received. -/// -/// The data type representative of distinct, streamable items. -internal class StreamingClientResult : AsyncResultCollection -{ - private readonly Func> _asyncEnumeratorSourceDelegate; - - // TODO: Add an overload that creates the type but delays sending the response - // for use with convenience methods. Do we need to take a func in the public - // method or is there another way? - - private StreamingClientResult(PipelineResponse response, Func> asyncEnumeratorSourceDelegate) - : base(response) - { - Argument.AssertNotNull(response, nameof(response)); - - if (response.ContentStream is null) - { - throw new ArgumentException("Unable to create result from response with null ContentStream", nameof(response)); - } - - _asyncEnumeratorSourceDelegate = asyncEnumeratorSourceDelegate; - } - - /// - /// Creates a new instance of that will yield items of the specified type - /// as they become available via server-sent event JSON data on the available - /// . This overload uses via the - /// interface and only supports single-item deserialization per server-sent event data - /// payload. - /// - /// The base for this result instance. - /// - /// The optional cancellation token used to control the enumeration. - /// - /// A new instance of . - public static StreamingClientResult CreateStreaming(PipelineResponse response, CancellationToken cancellationToken = default) - where U : IJsonModel - { - return new(response, GetServerSentEventDeserializationEnumerator); - } - - public static StreamingClientResult CreateStreaming(PipelineResponse response, CancellationToken cancellationToken = default) - { - return new(response, GetBinaryDataEnumerator); - } - - private static async IAsyncEnumerator GetBinaryDataEnumerator(Stream stream, CancellationToken cancellationToken = default) - { - // TODO: handle via DisposeAsync instead? - - using ServerSentEventReader reader = new(stream); - using AsyncServerSentEventEnumerator sseEnumerator = new(reader, cancellationToken); - while (await sseEnumerator.MoveNextAsync().ConfigureAwait(false)) - { - // TODO: more efficient way? - char[] chars = sseEnumerator.Current.Data.ToArray(); - byte[] bytes = Encoding.UTF8.GetBytes(chars); - yield return new BinaryData(bytes); - } - } - - private static IAsyncEnumerator GetServerSentEventDeserializationEnumerator(Stream stream, CancellationToken cancellationToken = default) - where U : IJsonModel - { - ServerSentEventReader? sseReader = null; - AsyncServerSentEventEnumerator? sseEnumerator = null; - try - { - sseReader = new(stream); - sseEnumerator = new(sseReader, cancellationToken); - AsyncServerSentEventJsonDataEnumerator instanceEnumerator = new(sseEnumerator); - sseEnumerator = null; - sseReader = null; - return instanceEnumerator; - } - finally - { - // TODO: I think we may need to use DisposeAsync here instead? - sseEnumerator?.Dispose(); - sseReader?.Dispose(); - } - } - - public override IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken) - { - return _asyncEnumeratorSourceDelegate.Invoke(GetRawResponse().ContentStream!, cancellationToken); - } -} diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventJsonDataEnumeratorTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventJsonDataEnumeratorTests.cs index 4cbccc0c32deb..b394f41643494 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventJsonDataEnumeratorTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventJsonDataEnumeratorTests.cs @@ -2,28 +2,27 @@ // Licensed under the MIT License. using System.ClientModel.Internal; -using System.IO; using System.Threading.Tasks; using Azure.Core.TestFramework; +using ClientModel.Tests.Mocks; using NUnit.Framework; namespace System.ClientModel.Tests.Convenience; +// TODO: rename test file public class AsyncServerSentEventJsonDataEnumeratorTests { [Test] public async Task EnumeratesSingleEvents() { - Stream contentStream = BinaryData.FromString(_mockContent).ToStream(); - using ServerSentEventReader reader = new(contentStream); - using AsyncServerSentEventEnumerator eventEnumerator = new(reader); - using AsyncServerSentEventJsonDataEnumerator modelEnumerator = new(eventEnumerator); + MockPipelineResponse response = new(); + response.SetContent(_mockContent); + + AsyncSseValueResultCollection models = new(response); int i = 0; - while (await modelEnumerator.MoveNextAsync()) + await foreach (MockJsonModel model in models) { - MockJsonModel model = modelEnumerator.Current; - Assert.AreEqual(model.IntValue, i); Assert.AreEqual(model.StringValue, i.ToString()); diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/StreamingClientResultTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/StreamingClientResultTests.cs index 1b3cff691faa0..730208d5689cb 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/StreamingClientResultTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/StreamingClientResultTests.cs @@ -1,54 +1,54 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using System.ClientModel.Internal; -using System.Threading.Tasks; -using Azure.Core.TestFramework; -using ClientModel.Tests.Mocks; -using NUnit.Framework; +//using System.ClientModel.Internal; +//using System.Threading.Tasks; +//using Azure.Core.TestFramework; +//using ClientModel.Tests.Mocks; +//using NUnit.Framework; -namespace System.ClientModel.Tests.Convenience; +//namespace System.ClientModel.Tests.Convenience; -public class StreamingClientResultTests -{ - [Test] - public async Task EnumeratesModelValues() - { - MockPipelineResponse response = new(); - response.SetContent(_mockContent); - var results = StreamingClientResult.Create(response); +//public class StreamingClientResultTests +//{ +// [Test] +// public async Task EnumeratesModelValues() +// { +// MockPipelineResponse response = new(); +// response.SetContent(_mockContent); +// var results = StreamingClientResult.Create(response); - int i = 0; - await foreach (MockJsonModel model in results) - { - Assert.AreEqual(model.IntValue, i); - Assert.AreEqual(model.StringValue, i.ToString()); +// int i = 0; +// await foreach (MockJsonModel model in results) +// { +// Assert.AreEqual(model.IntValue, i); +// Assert.AreEqual(model.StringValue, i.ToString()); - i++; - } +// i++; +// } - Assert.AreEqual(i, 3); - } +// Assert.AreEqual(i, 3); +// } - // TODO: Add tests for dispose and handling cancellation token - // TODO: later, add tests for varying the _doneToken value. +// // TODO: Add tests for dispose and handling cancellation token +// // TODO: later, add tests for varying the _doneToken value. - #region Helpers +// #region Helpers - private readonly string _mockContent = """ - event: event.0 - data: { "IntValue": 0, "StringValue": "0" } +// private readonly string _mockContent = """ +// event: event.0 +// data: { "IntValue": 0, "StringValue": "0" } - event: event.1 - data: { "IntValue": 1, "StringValue": "1" } +// event: event.1 +// data: { "IntValue": 1, "StringValue": "1" } - event: event.2 - data: { "IntValue": 2, "StringValue": "2" } +// event: event.2 +// data: { "IntValue": 2, "StringValue": "2" } - event: done - data: [DONE] +// event: done +// data: [DONE] - """; +// """; - #endregion -} +// #endregion +//} From fc84db1a4f0d38003a4b0069654ba9bd6d0bb3b5 Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Mon, 6 May 2024 10:14:56 -0700 Subject: [PATCH 18/45] renames --- .../src/Convenience/AsyncResultCollectionOfT.cs | 2 +- ...ataEnumerable.cs => AsyncSseBinaryDataResultCollection.cs} | 4 +--- ...SseValueEnumerable.cs => AsyncSseValueResultCollection.cs} | 4 +--- 3 files changed, 3 insertions(+), 7 deletions(-) rename sdk/core/System.ClientModel/src/Internal/SSE/{AsyncSseDataEnumerable.cs => AsyncSseBinaryDataResultCollection.cs} (91%) rename sdk/core/System.ClientModel/src/Internal/SSE/{AsyncSseValueEnumerable.cs => AsyncSseValueResultCollection.cs} (92%) diff --git a/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs b/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs index 53230b0b11990..3c6e5782d309b 100644 --- a/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs +++ b/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs @@ -23,7 +23,7 @@ protected internal AsyncResultCollection(PipelineResponse response) : base(respo public abstract IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default); // TODO: take CancellationToken -- question -- does the cancellation token go here or to the enumerator? - //public static ClientResultCollection Create(PipelineResponse response) where TValue : IJsonModel + // TODO: Consider signature: `public static ClientResultCollection Create(PipelineResponse response) where TValue : IJsonModel` ? // TODO: terminal event can be a model type as well ... are we happy using string for now and adding an overload if needed later? public static AsyncResultCollection Create(PipelineResponse response, CancellationToken cancellationToken = default) where TValue : IJsonModel diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseDataEnumerable.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseBinaryDataResultCollection.cs similarity index 91% rename from sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseDataEnumerable.cs rename to sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseBinaryDataResultCollection.cs index 0ba11bfd37d49..e98dbaff8d1b6 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseDataEnumerable.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseBinaryDataResultCollection.cs @@ -24,7 +24,7 @@ public override IAsyncEnumerator GetAsyncEnumerator(CancellationToke PipelineResponse response = GetRawResponse(); // We validate that response.ContentStream is non-null in - // AsyncResultCollection.Create method. + // AsyncResultCollection.Create factory method. Debug.Assert(response.ContentStream is not null); ServerSentEventReader reader = new(response.ContentStream!); @@ -32,8 +32,6 @@ public override IAsyncEnumerator GetAsyncEnumerator(CancellationToke return new AsyncSseDataEnumerator(sseEnumerator); } - // TODO: probably change the name back to AsyncSseBinaryDataEnumerator. - // Right now, I want to reason about it as "the thing that enumerates data elements as BinaryData." private class AsyncSseDataEnumerator : IAsyncEnumerator, IDisposable, IAsyncDisposable { private readonly AsyncServerSentEventEnumerator _eventEnumerator; diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseValueEnumerable.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseValueResultCollection.cs similarity index 92% rename from sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseValueEnumerable.cs rename to sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseValueResultCollection.cs index 34f7a37cbf47a..3ee6201472b39 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseValueEnumerable.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseValueResultCollection.cs @@ -23,7 +23,7 @@ public override IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancell PipelineResponse response = GetRawResponse(); // We validate that response.ContentStream is non-null in - // AsyncResultCollection.Create method. + // AsyncResultCollection.Create factory method. Debug.Assert(response.ContentStream is not null); ServerSentEventReader reader = new(response.ContentStream!); @@ -31,8 +31,6 @@ public override IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancell return new AsyncSseValueEnumerator(sseEnumerator); } - // TODO: probably change the name back to AsyncSseJsonModelEnumerator. - // Right now, I want to reason about it as "the thing that enumerates values." private class AsyncSseValueEnumerator : IAsyncEnumerator, IDisposable, IAsyncDisposable { private readonly AsyncServerSentEventEnumerator _eventEnumerator; From 5a1bd1356abbe827eb8736ec75fbde3eb8d46fb3 Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Mon, 6 May 2024 10:54:46 -0700 Subject: [PATCH 19/45] postpone call to protocol method from convenience APIs --- .../api/System.ClientModel.net6.0.cs | 2 +- .../api/System.ClientModel.netstandard2.0.cs | 2 +- .../Convenience/AsyncResultCollectionOfT.cs | 14 ++--- .../SSE/AsyncSseValueResultCollection.cs | 60 +++++++++++++------ .../ClientResultCollectionTests.cs | 21 ++++++- .../tests/TestFramework/Mocks/MockClient.cs | 12 +++- 6 files changed, 78 insertions(+), 33 deletions(-) diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs index c7da86f9b411c..b3262881965c8 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs @@ -12,7 +12,7 @@ public abstract partial class AsyncResultCollection : System.ClientModel.Clie protected internal AsyncResultCollection() : base (default(System.ClientModel.Primitives.PipelineResponse)) { } protected internal AsyncResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } public static System.ClientModel.AsyncResultCollection Create(System.ClientModel.Primitives.PipelineResponse response, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public static System.ClientModel.AsyncResultCollection Create(System.ClientModel.Primitives.PipelineResponse response, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) where TValue : System.ClientModel.Primitives.IJsonModel { throw null; } + public static System.ClientModel.AsyncResultCollection Create(System.Func> getResultAsync, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) where TValue : System.ClientModel.Primitives.IJsonModel { throw null; } public abstract System.Collections.Generic.IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); } public abstract partial class BinaryContent : System.IDisposable diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs index 0da0786daa3da..7dcfde12c0c70 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs @@ -12,7 +12,7 @@ public abstract partial class AsyncResultCollection : System.ClientModel.Clie protected internal AsyncResultCollection() : base (default(System.ClientModel.Primitives.PipelineResponse)) { } protected internal AsyncResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } public static System.ClientModel.AsyncResultCollection Create(System.ClientModel.Primitives.PipelineResponse response, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public static System.ClientModel.AsyncResultCollection Create(System.ClientModel.Primitives.PipelineResponse response, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) where TValue : System.ClientModel.Primitives.IJsonModel { throw null; } + public static System.ClientModel.AsyncResultCollection Create(System.Func> getResultAsync, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) where TValue : System.ClientModel.Primitives.IJsonModel { throw null; } public abstract System.Collections.Generic.IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); } public abstract partial class BinaryContent : System.IDisposable diff --git a/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs b/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs index 3c6e5782d309b..00759ae6cffc1 100644 --- a/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs +++ b/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs @@ -5,13 +5,14 @@ using System.ClientModel.Primitives; using System.Collections.Generic; using System.Threading; +using System.Threading.Tasks; namespace System.ClientModel; #pragma warning disable CS1591 // public XML comments public abstract class AsyncResultCollection : ClientResult, IAsyncEnumerable { - // Overload for lazily sending request + // Overload for sending request lazily protected internal AsyncResultCollection() : base(default) { } @@ -25,18 +26,13 @@ protected internal AsyncResultCollection(PipelineResponse response) : base(respo // TODO: take CancellationToken -- question -- does the cancellation token go here or to the enumerator? // TODO: Consider signature: `public static ClientResultCollection Create(PipelineResponse response) where TValue : IJsonModel` ? // TODO: terminal event can be a model type as well ... are we happy using string for now and adding an overload if needed later? - public static AsyncResultCollection Create(PipelineResponse response, CancellationToken cancellationToken = default) + public static AsyncResultCollection Create(Func> getResultAsync, CancellationToken cancellationToken = default) where TValue : IJsonModel { - Argument.AssertNotNull(response, nameof(response)); - - if (response.ContentStream is null) - { - throw new ArgumentException("Unable to create result from response with null ContentStream", nameof(response)); - } + Argument.AssertNotNull(getResultAsync, nameof(getResultAsync)); // TODO: correct pattern for cancellation token - return new AsyncSseValueResultCollection(response); + return new AsyncSseValueResultCollection(getResultAsync); } public static AsyncResultCollection Create(PipelineResponse response, CancellationToken cancellationToken = default) diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseValueResultCollection.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseValueResultCollection.cs index 3ee6201472b39..31e27efbce1b6 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseValueResultCollection.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseValueResultCollection.cs @@ -13,42 +13,62 @@ namespace System.ClientModel.Internal; internal class AsyncSseValueResultCollection : AsyncResultCollection where T : IJsonModel { - // TODO: delay sending request - public AsyncSseValueResultCollection(PipelineResponse response) : base(response) + private readonly Func> _getResultAsync; + + public AsyncSseValueResultCollection(Func> getResultAsync) : base() { + Debug.Assert(getResultAsync is not null); + + _getResultAsync = getResultAsync!; } public override IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) { - PipelineResponse response = GetRawResponse(); - - // We validate that response.ContentStream is non-null in - // AsyncResultCollection.Create factory method. - Debug.Assert(response.ContentStream is not null); - - ServerSentEventReader reader = new(response.ContentStream!); - AsyncServerSentEventEnumerator sseEnumerator = new(reader, cancellationToken); - return new AsyncSseValueEnumerator(sseEnumerator); + return new AsyncSseValueEnumerator(_getResultAsync, this); } private class AsyncSseValueEnumerator : IAsyncEnumerator, IDisposable, IAsyncDisposable { - private readonly AsyncServerSentEventEnumerator _eventEnumerator; + private readonly Func> _getResultAsync; + private readonly AsyncSseValueResultCollection _resultCollection; + private AsyncServerSentEventEnumerator? _eventEnumerator; private T? _current; // TODO: is null supression the correct pattern here? public T Current { get => _current!; } - public AsyncSseValueEnumerator(AsyncServerSentEventEnumerator eventEnumerator) + public AsyncSseValueEnumerator(Func> getResultAsync, AsyncSseValueResultCollection resultCollection) { - Argument.AssertNotNull(eventEnumerator, nameof(eventEnumerator)); + Debug.Assert(getResultAsync is not null); + Debug.Assert(resultCollection is not null); - _eventEnumerator = eventEnumerator; + _getResultAsync = getResultAsync!; + _resultCollection = resultCollection!; } public async ValueTask MoveNextAsync() { + // Lazily initialize + // TODO: refactor for clarity + if (_eventEnumerator is null) + { + ClientResult result = await _getResultAsync().ConfigureAwait(false); + PipelineResponse response = result.GetRawResponse(); + + if (response.ContentStream is null) + { + throw new ArgumentException("Unable to create result from response with null ContentStream", nameof(response)); + } + + _resultCollection.SetRawResponse(response); + + ServerSentEventReader reader = new(response.ContentStream!); + + // TODO: correct pattern for cancellation token. + _eventEnumerator = new(reader /*, cancellationToken */); + } + if (await _eventEnumerator.MoveNextAsync().ConfigureAwait(false)) { using JsonDocument eventDocument = JsonDocument.Parse(_eventEnumerator.Current.Data); @@ -68,17 +88,23 @@ public async ValueTask MoveNextAsync() return true; } } + return false; } public async ValueTask DisposeAsync() { - await _eventEnumerator.DisposeAsync().ConfigureAwait(false); + // TODO: implement dispose async correctly + var enumerator = _eventEnumerator; + if (enumerator is not null) + { + await enumerator.DisposeAsync().ConfigureAwait(false); + } } public void Dispose() { - _eventEnumerator.Dispose(); + _eventEnumerator?.Dispose(); } } } diff --git a/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs b/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs index 300c05c7e4514..20a653b10c592 100644 --- a/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs +++ b/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs @@ -15,13 +15,23 @@ public ClientResultCollectionTests(bool isAsync) : base(isAsync) { } + // TODO: add tests for protocol methods we need to pass parameters to + // to show this method signature works with closures as expected. + [Test] public async Task CreatesAsyncResultCollection() { MockPipelineResponse response = new(); response.SetContent("[DONE]"); - var results = AsyncResultCollection.Create(response); + Func> getResultAsync = async () => + { + // TODO: simulate async correctly + await Task.Delay(0); + return ClientResult.FromResponse(response); + }; + + var results = AsyncResultCollection.Create(getResultAsync); bool empty = true; await foreach (MockJsonModel result in results) @@ -39,7 +49,14 @@ public async Task CanEnumerateModelValues() { MockPipelineResponse response = new(); response.SetContent(_mockContent); - var results = AsyncResultCollection.Create(response); + + Func> getResultAsync = async () => + { + await Task.Delay(0); + return ClientResult.FromResponse(response); + }; + + var results = AsyncResultCollection.Create(getResultAsync); int i = 0; await foreach (MockJsonModel model in results) diff --git a/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs b/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs index 049a66957d8a4..daba21aa0e7e7 100644 --- a/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs +++ b/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs @@ -6,6 +6,7 @@ using System.ClientModel.Primitives; using System.Collections.Generic; using System.Threading; +using System.Threading.Tasks; using Azure.Core.TestFramework; namespace ClientModel.Tests.Mocks; @@ -89,9 +90,14 @@ public MockJsonModelCollection(string content, Func GetAsyncEnumerator(CancellationToken cancellationToken = default) { - ClientResult result = _protocolMethod(_content, /*options:*/ default); - PipelineResponse response = result.GetRawResponse(); - AsyncResultCollection enumerable = Create(response, cancellationToken); + Func> getResultAsync = async () => + { + // TODO: simulate async correctly + await Task.Delay(0); + return _protocolMethod(_content, /*options:*/ default); + }; + + AsyncResultCollection enumerable = Create(getResultAsync, cancellationToken); return enumerable.GetAsyncEnumerator(cancellationToken); } } From 75e3e8a52a1b346090c1c5dec5de01065556d606 Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Mon, 6 May 2024 13:51:07 -0700 Subject: [PATCH 20/45] implement IAsyncDisposable correctly --- .../SSE/AsyncServerSentEventEnumerator.cs | 38 +++++------ .../SSE/AsyncSseBinaryDataResultCollection.cs | 33 ++++++---- .../SSE/AsyncSseValueResultCollection.cs | 39 ++++++----- .../src/Internal/SSE/ServerSentEventReader.cs | 65 ++++++++++++++----- 4 files changed, 109 insertions(+), 66 deletions(-) diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs index d2011b8768549..1ffbf335339c7 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs @@ -7,14 +7,13 @@ namespace System.ClientModel.Internal; -internal sealed class AsyncServerSentEventEnumerator : IAsyncEnumerator, IDisposable, IAsyncDisposable +internal sealed class AsyncServerSentEventEnumerator : IAsyncEnumerator { // TODO: make this configurable per coming from TypeSpec private static readonly ReadOnlyMemory _doneToken = "[DONE]".AsMemory(); - private readonly ServerSentEventReader _reader; private readonly CancellationToken _cancellationToken; - private bool _disposedValue; + private ServerSentEventReader? _reader; public ServerSentEvent Current { get; private set; } @@ -26,6 +25,11 @@ public AsyncServerSentEventEnumerator(ServerSentEventReader reader, Cancellation public async ValueTask MoveNextAsync() { + if (_reader is null) + { + throw new ObjectDisposedException(nameof(AsyncServerSentEventEnumerator)); + } + ServerSentEvent? nextEvent = await _reader.TryGetNextEventAsync(_cancellationToken).ConfigureAwait(false); if (nextEvent.HasValue) { @@ -33,35 +37,27 @@ public async ValueTask MoveNextAsync() { return false; } + Current = nextEvent.Value; return true; } + return false; } - private void Dispose(bool disposing) + public async ValueTask DisposeAsync() { - if (!_disposedValue) - { - if (disposing) - { - _reader.Dispose(); - } - - _disposedValue = true; - } - } + await DisposeAsyncCore().ConfigureAwait(false); - public void Dispose() - { - // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method - Dispose(disposing: true); GC.SuppressFinalize(this); } - public ValueTask DisposeAsync() + private async ValueTask DisposeAsyncCore() { - Dispose(); - return new ValueTask(); + if (_reader is not null) + { + await _reader.DisposeAsync().ConfigureAwait(false); + _reader = null; + } } } diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseBinaryDataResultCollection.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseBinaryDataResultCollection.cs index e98dbaff8d1b6..a4d5a9bb505ee 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseBinaryDataResultCollection.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseBinaryDataResultCollection.cs @@ -32,27 +32,31 @@ public override IAsyncEnumerator GetAsyncEnumerator(CancellationToke return new AsyncSseDataEnumerator(sseEnumerator); } - private class AsyncSseDataEnumerator : IAsyncEnumerator, IDisposable, IAsyncDisposable + private sealed class AsyncSseDataEnumerator : IAsyncEnumerator { - private readonly AsyncServerSentEventEnumerator _eventEnumerator; - + private AsyncServerSentEventEnumerator? _events; private BinaryData? _current; // TODO: is null supression the correct pattern here? public BinaryData Current { get => _current!; } - public AsyncSseDataEnumerator(AsyncServerSentEventEnumerator eventEnumerator) + public AsyncSseDataEnumerator(AsyncServerSentEventEnumerator events) { - Argument.AssertNotNull(eventEnumerator, nameof(eventEnumerator)); + Debug.Assert(events is not null); - _eventEnumerator = eventEnumerator; + _events = events; } public async ValueTask MoveNextAsync() { - if (await _eventEnumerator.MoveNextAsync().ConfigureAwait(false)) + if (_events is null) + { + throw new ObjectDisposedException(nameof(AsyncSseDataEnumerator)); + } + + if (await _events.MoveNextAsync().ConfigureAwait(false)) { - char[] chars = _eventEnumerator.Current.Data.ToArray(); + char[] chars = _events.Current.Data.ToArray(); byte[] bytes = Encoding.UTF8.GetBytes(chars); _current = new BinaryData(bytes); return true; @@ -62,15 +66,20 @@ public async ValueTask MoveNextAsync() return false; } - // TODO: implement this pattern correctly. public async ValueTask DisposeAsync() { - await _eventEnumerator.DisposeAsync().ConfigureAwait(false); + await DisposeAsyncCore().ConfigureAwait(false); + + GC.SuppressFinalize(this); } - public void Dispose() + private async ValueTask DisposeAsyncCore() { - _eventEnumerator.Dispose(); + if (_events is not null) + { + await _events.DisposeAsync().ConfigureAwait(false); + _events = null; + } } } } diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseValueResultCollection.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseValueResultCollection.cs index 31e27efbce1b6..3c59b7154d96c 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseValueResultCollection.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseValueResultCollection.cs @@ -27,14 +27,16 @@ public override IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancell return new AsyncSseValueEnumerator(_getResultAsync, this); } - private class AsyncSseValueEnumerator : IAsyncEnumerator, IDisposable, IAsyncDisposable + private sealed class AsyncSseValueEnumerator : IAsyncEnumerator { private readonly Func> _getResultAsync; private readonly AsyncSseValueResultCollection _resultCollection; - private AsyncServerSentEventEnumerator? _eventEnumerator; + private AsyncServerSentEventEnumerator? _events; private T? _current; + private bool _started; + // TODO: is null supression the correct pattern here? public T Current { get => _current!; } @@ -49,10 +51,15 @@ public AsyncSseValueEnumerator(Func> getResultAsync, AsyncSse public async ValueTask MoveNextAsync() { - // Lazily initialize // TODO: refactor for clarity - if (_eventEnumerator is null) + if (_events is null) { + if (_started) + { + throw new ObjectDisposedException(nameof(AsyncSseValueEnumerator)); + } + + // Lazily initialize ClientResult result = await _getResultAsync().ConfigureAwait(false); PipelineResponse response = result.GetRawResponse(); @@ -62,16 +69,17 @@ public async ValueTask MoveNextAsync() } _resultCollection.SetRawResponse(response); + _started = true; ServerSentEventReader reader = new(response.ContentStream!); // TODO: correct pattern for cancellation token. - _eventEnumerator = new(reader /*, cancellationToken */); + _events = new(reader /*, cancellationToken */); } - if (await _eventEnumerator.MoveNextAsync().ConfigureAwait(false)) + if (await _events.MoveNextAsync().ConfigureAwait(false)) { - using JsonDocument eventDocument = JsonDocument.Parse(_eventEnumerator.Current.Data); + using JsonDocument eventDocument = JsonDocument.Parse(_events.Current.Data); BinaryData eventData = BinaryData.FromObjectAsJson(eventDocument.RootElement); T? jsonData = ModelReaderWriter.Read(eventData); @@ -94,17 +102,18 @@ public async ValueTask MoveNextAsync() public async ValueTask DisposeAsync() { - // TODO: implement dispose async correctly - var enumerator = _eventEnumerator; - if (enumerator is not null) - { - await enumerator.DisposeAsync().ConfigureAwait(false); - } + await DisposeAsyncCore().ConfigureAwait(false); + + GC.SuppressFinalize(this); } - public void Dispose() + private async ValueTask DisposeAsyncCore() { - _eventEnumerator?.Dispose(); + if (_events is not null) + { + await _events.DisposeAsync().ConfigureAwait(false); + _events = null; + } } } } diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs index b0b68c6e276eb..f2530b84dcb77 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs @@ -11,9 +11,8 @@ namespace System.ClientModel.Internal; // TODO: Different sync and async readers to dispose differently? internal sealed class ServerSentEventReader : IDisposable, IAsyncDisposable { - private readonly Stream _stream; - private readonly StreamReader _reader; - private bool _disposedValue; + private Stream? _stream; + private StreamReader? _reader; public ServerSentEventReader(Stream stream) { @@ -31,11 +30,17 @@ public ServerSentEventReader(Stream stream) /// public ServerSentEvent? TryGetNextEvent(CancellationToken cancellationToken = default) { + if (_reader is null) + { + throw new ObjectDisposedException(nameof(ServerSentEventReader)); + } + List fields = new(); while (!cancellationToken.IsCancellationRequested) { string? line = _reader.ReadLine(); + if (line == null) { // A null line indicates end of input @@ -73,11 +78,17 @@ public ServerSentEventReader(Stream stream) /// public async Task TryGetNextEventAsync(CancellationToken cancellationToken = default) { + if (_reader is null) + { + throw new ObjectDisposedException(nameof(ServerSentEventReader)); + } + List fields = new(); while (!cancellationToken.IsCancellationRequested) { string? line = await _reader.ReadLineAsync().ConfigureAwait(false); + if (line == null) { // A null line indicates end of input @@ -112,27 +123,45 @@ public void Dispose() private void Dispose(bool disposing) { - if (!_disposedValue) + if (disposing) { - if (disposing) - { - _reader.Dispose(); - _stream.Dispose(); - } + _reader?.Dispose(); + _reader = null; - _disposedValue = true; + _stream?.Dispose(); + _stream = null; } } - // TODO: get this pattern right public async ValueTask DisposeAsync() { -#if NETSTANDARD2_0 - // TODO: is this the right pattern for calling sync methods in - // async contexts? - await Task.Run(_stream.Dispose).ConfigureAwait(false); -#else - await _stream.DisposeAsync().ConfigureAwait(false); -#endif + await DisposeAsyncCore().ConfigureAwait(false); + + Dispose(disposing: false); + GC.SuppressFinalize(this); + } + + private async ValueTask DisposeAsyncCore() + { + if (_reader is IAsyncDisposable reader) + { + await reader.DisposeAsync().ConfigureAwait(false); + } + else + { + _reader?.Dispose(); + } + + if (_stream is IAsyncDisposable stream) + { + await stream.DisposeAsync().ConfigureAwait(false); + } + else + { + _stream?.Dispose(); + } + + _reader = null; + _stream = null; } } From 80e6ee520ebce8dd4a0f93d6ca764c3ab81bddeb Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Mon, 6 May 2024 14:55:16 -0700 Subject: [PATCH 21/45] initial pass over cancellation token --- .../api/System.ClientModel.net6.0.cs | 4 +- .../api/System.ClientModel.netstandard2.0.cs | 4 +- .../Convenience/AsyncResultCollectionOfT.cs | 24 +++++--- .../SSE/AsyncServerSentEventEnumerator.cs | 12 +++- .../SSE/AsyncSseBinaryDataResultCollection.cs | 25 +++++--- .../SSE/AsyncSseValueResultCollection.cs | 35 ++++++----- .../src/Internal/SSE/ServerSentEventReader.cs | 20 +++--- .../ClientResultCollectionTests.cs | 61 ++++++++++++++++++- .../tests/TestFramework/Mocks/MockClient.cs | 8 +-- .../AsyncServerSentEventEnumeratorTests.cs | 5 +- ...cServerSentEventJsonDataEnumeratorTests.cs | 9 ++- 11 files changed, 153 insertions(+), 54 deletions(-) diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs index b3262881965c8..dea67751158ff 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs @@ -11,8 +11,8 @@ public abstract partial class AsyncResultCollection : System.ClientModel.Clie { protected internal AsyncResultCollection() : base (default(System.ClientModel.Primitives.PipelineResponse)) { } protected internal AsyncResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } - public static System.ClientModel.AsyncResultCollection Create(System.ClientModel.Primitives.PipelineResponse response, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public static System.ClientModel.AsyncResultCollection Create(System.Func> getResultAsync, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) where TValue : System.ClientModel.Primitives.IJsonModel { throw null; } + public static System.ClientModel.AsyncResultCollection Create(System.ClientModel.Primitives.PipelineResponse response) { throw null; } + public static System.ClientModel.AsyncResultCollection Create(System.Func> getResultAsync) where TValue : System.ClientModel.Primitives.IJsonModel { throw null; } public abstract System.Collections.Generic.IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); } public abstract partial class BinaryContent : System.IDisposable diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs index 7dcfde12c0c70..500e3176c02cd 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs @@ -11,8 +11,8 @@ public abstract partial class AsyncResultCollection : System.ClientModel.Clie { protected internal AsyncResultCollection() : base (default(System.ClientModel.Primitives.PipelineResponse)) { } protected internal AsyncResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } - public static System.ClientModel.AsyncResultCollection Create(System.ClientModel.Primitives.PipelineResponse response, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public static System.ClientModel.AsyncResultCollection Create(System.Func> getResultAsync, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) where TValue : System.ClientModel.Primitives.IJsonModel { throw null; } + public static System.ClientModel.AsyncResultCollection Create(System.ClientModel.Primitives.PipelineResponse response) { throw null; } + public static System.ClientModel.AsyncResultCollection Create(System.Func> getResultAsync) where TValue : System.ClientModel.Primitives.IJsonModel { throw null; } public abstract System.Collections.Generic.IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); } public abstract partial class BinaryContent : System.IDisposable diff --git a/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs b/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs index 00759ae6cffc1..76db2b089bf16 100644 --- a/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs +++ b/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs @@ -12,40 +12,46 @@ namespace System.ClientModel; #pragma warning disable CS1591 // public XML comments public abstract class AsyncResultCollection : ClientResult, IAsyncEnumerable { - // Overload for sending request lazily + // Constructor overload for collection implementations that postpone + // sending a request until GetAsyncEnumerator is called. This will typically + // be used by collections returned from client convenience methods. protected internal AsyncResultCollection() : base(default) { } + // Constructor overload for collection implementations where the service + // has returned a response. This will typically be used by collections + // created from the return result of a client's protocol method. protected internal AsyncResultCollection(PipelineResponse response) : base(response) { } - public abstract IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default); + #region Factory methods - // TODO: take CancellationToken -- question -- does the cancellation token go here or to the enumerator? // TODO: Consider signature: `public static ClientResultCollection Create(PipelineResponse response) where TValue : IJsonModel` ? // TODO: terminal event can be a model type as well ... are we happy using string for now and adding an overload if needed later? - public static AsyncResultCollection Create(Func> getResultAsync, CancellationToken cancellationToken = default) + public static AsyncResultCollection Create(Func> getResultAsync) where TValue : IJsonModel { Argument.AssertNotNull(getResultAsync, nameof(getResultAsync)); - // TODO: correct pattern for cancellation token return new AsyncSseValueResultCollection(getResultAsync); } - public static AsyncResultCollection Create(PipelineResponse response, CancellationToken cancellationToken = default) + public static AsyncResultCollection Create(PipelineResponse response) { Argument.AssertNotNull(response, nameof(response)); if (response.ContentStream is null) { - throw new ArgumentException("Unable to create result from response with null ContentStream", nameof(response)); + throw new ArgumentException("Unable to create result collection from PipelineResponse with null ContentStream", nameof(response)); } - // TODO: correct pattern for cancellation token - return new AsyncSseDataResultCollection(response); + return new AsyncSseBinaryDataResultCollection(response); } + + #endregion + + public abstract IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default); } #pragma warning restore CS1591 // public XML comments diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs index 1ffbf335339c7..2fe0fc88841ef 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Collections.Generic; +using System.IO; using System.Threading; using System.Threading.Tasks; @@ -17,9 +18,9 @@ internal sealed class AsyncServerSentEventEnumerator : IAsyncEnumerator MoveNextAsync() throw new ObjectDisposedException(nameof(AsyncServerSentEventEnumerator)); } + if (_cancellationToken.IsCancellationRequested) + { + // TODO: correct to return false in this case? + // Or do we throw TaskCancelledException? + return false; + } + ServerSentEvent? nextEvent = await _reader.TryGetNextEventAsync(_cancellationToken).ConfigureAwait(false); if (nextEvent.HasValue) { diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseBinaryDataResultCollection.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseBinaryDataResultCollection.cs index a4d5a9bb505ee..2986c8cc44f51 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseBinaryDataResultCollection.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseBinaryDataResultCollection.cs @@ -4,17 +4,18 @@ using System.ClientModel.Primitives; using System.Collections.Generic; using System.Diagnostics; +using System.IO; using System.Text; using System.Threading; using System.Threading.Tasks; namespace System.ClientModel.Internal; -internal class AsyncSseDataResultCollection : AsyncResultCollection +internal class AsyncSseBinaryDataResultCollection : AsyncResultCollection { // Note: this one doesn't delay sending the request because it's used // with protocol methods. - public AsyncSseDataResultCollection(PipelineResponse response) : base(response) + public AsyncSseBinaryDataResultCollection(PipelineResponse response) : base(response) { Argument.AssertNotNull(response, nameof(response)); } @@ -27,28 +28,36 @@ public override IAsyncEnumerator GetAsyncEnumerator(CancellationToke // AsyncResultCollection.Create factory method. Debug.Assert(response.ContentStream is not null); - ServerSentEventReader reader = new(response.ContentStream!); - AsyncServerSentEventEnumerator sseEnumerator = new(reader, cancellationToken); - return new AsyncSseDataEnumerator(sseEnumerator); + return new AsyncSseDataEnumerator(response.ContentStream!, cancellationToken); } private sealed class AsyncSseDataEnumerator : IAsyncEnumerator { private AsyncServerSentEventEnumerator? _events; private BinaryData? _current; + private readonly CancellationToken _cancellationToken; // TODO: is null supression the correct pattern here? public BinaryData Current { get => _current!; } - public AsyncSseDataEnumerator(AsyncServerSentEventEnumerator events) + public AsyncSseDataEnumerator(Stream contentStream, CancellationToken cancellationToken) { - Debug.Assert(events is not null); + Debug.Assert(contentStream is not null); - _events = events; + // TODO: concerns about passing cancellationToken twice? + _events = new(contentStream!, cancellationToken); + _cancellationToken = cancellationToken; } public async ValueTask MoveNextAsync() { + if (_cancellationToken.IsCancellationRequested) + { + // TODO: correct to return false in this case? + // Or do we throw TaskCancelledException? + return false; + } + if (_events is null) { throw new ObjectDisposedException(nameof(AsyncSseDataEnumerator)); diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseValueResultCollection.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseValueResultCollection.cs index 3c59b7154d96c..b917f3f44f8db 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseValueResultCollection.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseValueResultCollection.cs @@ -24,13 +24,14 @@ public AsyncSseValueResultCollection(Func> getResultAsync) : public override IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) { - return new AsyncSseValueEnumerator(_getResultAsync, this); + return new AsyncSseValueEnumerator(_getResultAsync, this, cancellationToken); } private sealed class AsyncSseValueEnumerator : IAsyncEnumerator { private readonly Func> _getResultAsync; private readonly AsyncSseValueResultCollection _resultCollection; + private readonly CancellationToken _cancellationToken; private AsyncServerSentEventEnumerator? _events; private T? _current; @@ -40,41 +41,47 @@ private sealed class AsyncSseValueEnumerator : IAsyncEnumerator // TODO: is null supression the correct pattern here? public T Current { get => _current!; } - public AsyncSseValueEnumerator(Func> getResultAsync, AsyncSseValueResultCollection resultCollection) + public AsyncSseValueEnumerator(Func> getResultAsync, AsyncSseValueResultCollection resultCollection, CancellationToken cancellationToken) { Debug.Assert(getResultAsync is not null); Debug.Assert(resultCollection is not null); _getResultAsync = getResultAsync!; _resultCollection = resultCollection!; + _cancellationToken = cancellationToken; } public async ValueTask MoveNextAsync() { + if (_events is null && _started) + { + throw new ObjectDisposedException(nameof(AsyncSseValueEnumerator)); + } + + if (_cancellationToken.IsCancellationRequested) + { + // TODO: correct to return false in this case? + // Or do we throw TaskCancelledException? + return false; + } + // TODO: refactor for clarity + // Lazily initialize if (_events is null) { - if (_started) - { - throw new ObjectDisposedException(nameof(AsyncSseValueEnumerator)); - } - - // Lazily initialize + // TODO: show that cancellation token can be handled as part of + // the closure and doesn't need to be passed? Or ... no? ClientResult result = await _getResultAsync().ConfigureAwait(false); PipelineResponse response = result.GetRawResponse(); + _resultCollection.SetRawResponse(response); if (response.ContentStream is null) { throw new ArgumentException("Unable to create result from response with null ContentStream", nameof(response)); } - _resultCollection.SetRawResponse(response); + _events = new(response.ContentStream, _cancellationToken); _started = true; - - ServerSentEventReader reader = new(response.ContentStream!); - - // TODO: correct pattern for cancellation token. - _events = new(reader /*, cancellationToken */); } if (await _events.MoveNextAsync().ConfigureAwait(false)) diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs index f2530b84dcb77..9c8147a260a5d 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -35,8 +36,7 @@ public ServerSentEventReader(Stream stream) throw new ObjectDisposedException(nameof(ServerSentEventReader)); } - List fields = new(); - + List? fields = default; while (!cancellationToken.IsCancellationRequested) { string? line = _reader.ReadLine(); @@ -48,9 +48,11 @@ public ServerSentEventReader(Stream stream) } else if (line.Length == 0) { + Debug.Assert(fields is not null); + // An empty line should dispatch an event for pending accumulated fields - ServerSentEvent nextEvent = new(fields); - fields = new(); + ServerSentEvent nextEvent = new(fields!); + fields = default; return nextEvent; } else if (line[0] == ':') @@ -61,6 +63,7 @@ public ServerSentEventReader(Stream stream) else { // Otherwise, process the the field + value and accumulate it for the next dispatched event + fields ??= new(); fields.Add(new ServerSentEventField(line)); } } @@ -83,8 +86,7 @@ public ServerSentEventReader(Stream stream) throw new ObjectDisposedException(nameof(ServerSentEventReader)); } - List fields = new(); - + List? fields = default; while (!cancellationToken.IsCancellationRequested) { string? line = await _reader.ReadLineAsync().ConfigureAwait(false); @@ -96,8 +98,11 @@ public ServerSentEventReader(Stream stream) } else if (line.Length == 0) { + Debug.Assert(fields is not null); + // An empty line should dispatch an event for pending accumulated fields - ServerSentEvent nextEvent = new(fields); + ServerSentEvent nextEvent = new(fields!); + fields = default; return nextEvent; } else if (line[0] == ':') @@ -108,6 +113,7 @@ public ServerSentEventReader(Stream stream) else { // Otherwise, process the the field + value and accumulate it for the next dispatched event + fields ??= new(); fields.Add(new ServerSentEventField(line)); } } diff --git a/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs b/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs index 20a653b10c592..21f6b1436a839 100644 --- a/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs +++ b/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System.Threading; using System.Threading.Tasks; using Azure.Core.TestFramework; using ClientModel.Tests.Mocks; @@ -24,12 +25,12 @@ public async Task CreatesAsyncResultCollection() MockPipelineResponse response = new(); response.SetContent("[DONE]"); - Func> getResultAsync = async () => + async Task getResultAsync() { // TODO: simulate async correctly await Task.Delay(0); return ClientResult.FromResponse(response); - }; + } var results = AsyncResultCollection.Create(getResultAsync); @@ -112,6 +113,62 @@ public async Task CanDelaySendingRequest() Assert.IsTrue(client.StreamingProtocolMethodCalled); } + [Test] + public async Task CanEnumerateModelValuesWithCancellationToken() + { + MockPipelineResponse response = new(); + response.SetContent(_mockContent); + + Func> getResultAsync = async () => + { + await Task.Delay(0); + return ClientResult.FromResponse(response); + }; + + var results = AsyncResultCollection.Create(getResultAsync); + + // Set it to `cancelled: true` to validate functionality. + CancellationToken token = new(true); + + int i = 0; + await foreach (MockJsonModel model in results.WithCancellation(token)) + { + Assert.AreEqual(model.IntValue, i); + Assert.AreEqual(model.StringValue, i.ToString()); + + i++; + } + + // Don't enumerate any because token was cancelled. + Assert.AreEqual(i, 0); + } + + [Test] + public async Task CanEnumerateBinaryDataValuesWithCancellationToken() + { + MockPipelineResponse response = new(); + response.SetContent(_mockContent); + + var results = AsyncResultCollection.Create(response); + + // Set it to `cancelled: true` to validate functionality. + CancellationToken token = new(true); + + int i = 0; + await foreach (BinaryData value in results.WithCancellation(token)) + { + MockJsonModel model = value.ToObjectFromJson(); + + Assert.AreEqual(model.IntValue, i); + Assert.AreEqual(model.StringValue, i.ToString()); + + i++; + } + + // Don't enumerate any because token was cancelled. + Assert.AreEqual(i, 0); + } + #region Helpers private readonly string _mockContent = """ diff --git a/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs b/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs index daba21aa0e7e7..20e244fd82f36 100644 --- a/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs +++ b/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs @@ -90,14 +90,14 @@ public MockJsonModelCollection(string content, Func GetAsyncEnumerator(CancellationToken cancellationToken = default) { - Func> getResultAsync = async () => + async Task getResultAsync() { // TODO: simulate async correctly - await Task.Delay(0); + await Task.Delay(0, cancellationToken); return _protocolMethod(_content, /*options:*/ default); - }; + } - AsyncResultCollection enumerable = Create(getResultAsync, cancellationToken); + AsyncResultCollection enumerable = Create(getResultAsync); return enumerable.GetAsyncEnumerator(cancellationToken); } } diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumeratorTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumeratorTests.cs index c8eb562feabe1..df5094262e8f3 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumeratorTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumeratorTests.cs @@ -13,9 +13,8 @@ public class AsyncServerSentEventEnumeratorTests [Test] public async Task EnumeratesSingleEvents() { - Stream contentStream = BinaryData.FromString(_mockContent).ToStream(); - using ServerSentEventReader reader = new(contentStream); - using AsyncServerSentEventEnumerator enumerator = new(reader); + using Stream contentStream = BinaryData.FromString(_mockContent).ToStream(); + AsyncServerSentEventEnumerator enumerator = new(contentStream); int i = 0; while (await enumerator.MoveNextAsync()) diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventJsonDataEnumeratorTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventJsonDataEnumeratorTests.cs index b394f41643494..2802472ebc196 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventJsonDataEnumeratorTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventJsonDataEnumeratorTests.cs @@ -18,7 +18,14 @@ public async Task EnumeratesSingleEvents() MockPipelineResponse response = new(); response.SetContent(_mockContent); - AsyncSseValueResultCollection models = new(response); + async Task getResultAsync() + { + // TODO: simulate async correctly + await Task.Delay(0); + return ClientResult.FromResponse(response); + } + + AsyncSseValueResultCollection models = new(getResultAsync); int i = 0; await foreach (MockJsonModel model in models) From c27581d1e8352e3d4bcc07cc50717c4e856a178f Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Mon, 6 May 2024 15:29:36 -0700 Subject: [PATCH 22/45] Per FDG, throw OperationCanceledException if cancellation token is cancelled. --- .../SSE/AsyncServerSentEventEnumerator.cs | 7 - .../SSE/AsyncSseBinaryDataResultCollection.cs | 10 -- .../SSE/AsyncSseValueResultCollection.cs | 17 +-- .../src/Internal/SSE/ServerSentEventReader.cs | 13 +- .../src/Pipeline/ClientRetryPolicy.cs | 2 +- .../ClientResultCollectionTests.cs | 40 ++---- .../AsyncServerSentEventEnumeratorTests.cs | 14 +- ...cServerSentEventJsonDataEnumeratorTests.cs | 64 --------- .../SSE/AsyncSseResultCollectionTests.cs | 129 ++++++++++++++++++ .../SSE/ServerSentEventReaderTests.cs | 13 ++ .../SSE/StreamingClientResultTests.cs | 54 -------- 11 files changed, 183 insertions(+), 180 deletions(-) delete mode 100644 sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventJsonDataEnumeratorTests.cs create mode 100644 sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncSseResultCollectionTests.cs delete mode 100644 sdk/core/System.ClientModel/tests/internal/Convenience/SSE/StreamingClientResultTests.cs diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs index 2fe0fc88841ef..8db7c1e5f62e9 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs @@ -31,13 +31,6 @@ public async ValueTask MoveNextAsync() throw new ObjectDisposedException(nameof(AsyncServerSentEventEnumerator)); } - if (_cancellationToken.IsCancellationRequested) - { - // TODO: correct to return false in this case? - // Or do we throw TaskCancelledException? - return false; - } - ServerSentEvent? nextEvent = await _reader.TryGetNextEventAsync(_cancellationToken).ConfigureAwait(false); if (nextEvent.HasValue) { diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseBinaryDataResultCollection.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseBinaryDataResultCollection.cs index 2986c8cc44f51..13ad4cf72a336 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseBinaryDataResultCollection.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseBinaryDataResultCollection.cs @@ -35,7 +35,6 @@ private sealed class AsyncSseDataEnumerator : IAsyncEnumerator { private AsyncServerSentEventEnumerator? _events; private BinaryData? _current; - private readonly CancellationToken _cancellationToken; // TODO: is null supression the correct pattern here? public BinaryData Current { get => _current!; } @@ -44,20 +43,11 @@ public AsyncSseDataEnumerator(Stream contentStream, CancellationToken cancellati { Debug.Assert(contentStream is not null); - // TODO: concerns about passing cancellationToken twice? _events = new(contentStream!, cancellationToken); - _cancellationToken = cancellationToken; } public async ValueTask MoveNextAsync() { - if (_cancellationToken.IsCancellationRequested) - { - // TODO: correct to return false in this case? - // Or do we throw TaskCancelledException? - return false; - } - if (_events is null) { throw new ObjectDisposedException(nameof(AsyncSseDataEnumerator)); diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseValueResultCollection.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseValueResultCollection.cs index b917f3f44f8db..188cd1d772c9f 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseValueResultCollection.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseValueResultCollection.cs @@ -30,7 +30,7 @@ public override IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancell private sealed class AsyncSseValueEnumerator : IAsyncEnumerator { private readonly Func> _getResultAsync; - private readonly AsyncSseValueResultCollection _resultCollection; + private readonly AsyncSseValueResultCollection _enumerable; private readonly CancellationToken _cancellationToken; private AsyncServerSentEventEnumerator? _events; @@ -41,13 +41,13 @@ private sealed class AsyncSseValueEnumerator : IAsyncEnumerator // TODO: is null supression the correct pattern here? public T Current { get => _current!; } - public AsyncSseValueEnumerator(Func> getResultAsync, AsyncSseValueResultCollection resultCollection, CancellationToken cancellationToken) + public AsyncSseValueEnumerator(Func> getResultAsync, AsyncSseValueResultCollection enumerable, CancellationToken cancellationToken) { Debug.Assert(getResultAsync is not null); - Debug.Assert(resultCollection is not null); + Debug.Assert(enumerable is not null); _getResultAsync = getResultAsync!; - _resultCollection = resultCollection!; + _enumerable = enumerable!; _cancellationToken = cancellationToken; } @@ -58,12 +58,7 @@ public async ValueTask MoveNextAsync() throw new ObjectDisposedException(nameof(AsyncSseValueEnumerator)); } - if (_cancellationToken.IsCancellationRequested) - { - // TODO: correct to return false in this case? - // Or do we throw TaskCancelledException? - return false; - } + _cancellationToken.ThrowIfCancellationRequested(); // TODO: refactor for clarity // Lazily initialize @@ -73,7 +68,7 @@ public async ValueTask MoveNextAsync() // the closure and doesn't need to be passed? Or ... no? ClientResult result = await _getResultAsync().ConfigureAwait(false); PipelineResponse response = result.GetRawResponse(); - _resultCollection.SetRawResponse(response); + _enumerable.SetRawResponse(response); if (response.ContentStream is null) { diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs index 9c8147a260a5d..be96148d7be1d 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs @@ -37,8 +37,11 @@ public ServerSentEventReader(Stream stream) } List? fields = default; - while (!cancellationToken.IsCancellationRequested) + while (true) { + cancellationToken.ThrowIfCancellationRequested(); + + // TODO: Pass cancellationToken? string? line = _reader.ReadLine(); if (line == null) @@ -67,8 +70,6 @@ public ServerSentEventReader(Stream stream) fields.Add(new ServerSentEventField(line)); } } - - return null; } /// @@ -87,8 +88,10 @@ public ServerSentEventReader(Stream stream) } List? fields = default; - while (!cancellationToken.IsCancellationRequested) + while (true) { + cancellationToken.ThrowIfCancellationRequested(); + string? line = await _reader.ReadLineAsync().ConfigureAwait(false); if (line == null) @@ -117,8 +120,6 @@ public ServerSentEventReader(Stream stream) fields.Add(new ServerSentEventField(line)); } } - - return null; } public void Dispose() diff --git a/sdk/core/System.ClientModel/src/Pipeline/ClientRetryPolicy.cs b/sdk/core/System.ClientModel/src/Pipeline/ClientRetryPolicy.cs index 3d297f4c83dda..6a66860c7c37f 100644 --- a/sdk/core/System.ClientModel/src/Pipeline/ClientRetryPolicy.cs +++ b/sdk/core/System.ClientModel/src/Pipeline/ClientRetryPolicy.cs @@ -305,7 +305,7 @@ protected virtual void Wait(TimeSpan time, CancellationToken cancellationToken) { if (cancellationToken.WaitHandle.WaitOne(time)) { - CancellationHelper.ThrowIfCancellationRequested(cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); } } } diff --git a/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs b/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs index 21f6b1436a839..3a4a4875cc6fc 100644 --- a/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs +++ b/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs @@ -79,9 +79,9 @@ public async Task CanEnumerateBinaryDataValues() AsyncResultCollection results = AsyncResultCollection.Create(response); int i = 0; - await foreach (BinaryData value in results) + await foreach (BinaryData result in results) { - MockJsonModel model = value.ToObjectFromJson(); + MockJsonModel model = result.ToObjectFromJson(); Assert.AreEqual(model.IntValue, i); Assert.AreEqual(model.StringValue, i.ToString()); @@ -114,7 +114,7 @@ public async Task CanDelaySendingRequest() } [Test] - public async Task CanEnumerateModelValuesWithCancellationToken() + public void ModelCollectionThrowsIfCancelled() { MockPipelineResponse response = new(); response.SetContent(_mockContent); @@ -130,21 +130,16 @@ public async Task CanEnumerateModelValuesWithCancellationToken() // Set it to `cancelled: true` to validate functionality. CancellationToken token = new(true); - int i = 0; - await foreach (MockJsonModel model in results.WithCancellation(token)) + Assert.ThrowsAsync(async () => { - Assert.AreEqual(model.IntValue, i); - Assert.AreEqual(model.StringValue, i.ToString()); - - i++; - } - - // Don't enumerate any because token was cancelled. - Assert.AreEqual(i, 0); + await foreach (MockJsonModel model in results.WithCancellation(token)) + { + } + }); } [Test] - public async Task CanEnumerateBinaryDataValuesWithCancellationToken() + public void BinaryDataCollectionThrowsIfCancelled() { MockPipelineResponse response = new(); response.SetContent(_mockContent); @@ -154,19 +149,12 @@ public async Task CanEnumerateBinaryDataValuesWithCancellationToken() // Set it to `cancelled: true` to validate functionality. CancellationToken token = new(true); - int i = 0; - await foreach (BinaryData value in results.WithCancellation(token)) + Assert.ThrowsAsync(async () => { - MockJsonModel model = value.ToObjectFromJson(); - - Assert.AreEqual(model.IntValue, i); - Assert.AreEqual(model.StringValue, i.ToString()); - - i++; - } - - // Don't enumerate any because token was cancelled. - Assert.AreEqual(i, 0); + await foreach (BinaryData result in results.WithCancellation(token)) + { + } + }); } #region Helpers diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumeratorTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumeratorTests.cs index df5094262e8f3..85a8297458596 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumeratorTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumeratorTests.cs @@ -3,6 +3,7 @@ using System.ClientModel.Internal; using System.IO; +using System.Threading; using System.Threading.Tasks; using NUnit.Framework; @@ -11,7 +12,7 @@ namespace System.ClientModel.Tests.Convenience; public class AsyncServerSentEventEnumeratorTests { [Test] - public async Task EnumeratesSingleEvents() + public async Task EnumeratesEvents() { using Stream contentStream = BinaryData.FromString(_mockContent).ToStream(); AsyncServerSentEventEnumerator enumerator = new(contentStream); @@ -30,6 +31,17 @@ public async Task EnumeratesSingleEvents() Assert.AreEqual(i, 3); } + [Test] + public void ThrowsIfCancelled() + { + CancellationToken token = new(true); + + using Stream contentStream = BinaryData.FromString(_mockContent).ToStream(); + AsyncServerSentEventEnumerator enumerator = new(contentStream, token); + + Assert.ThrowsAsync(async () => await enumerator.MoveNextAsync()); + } + // TODO: Add tests for dispose and handling cancellation token // TODO: later, add tests for varying the _doneToken value. diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventJsonDataEnumeratorTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventJsonDataEnumeratorTests.cs deleted file mode 100644 index 2802472ebc196..0000000000000 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventJsonDataEnumeratorTests.cs +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System.ClientModel.Internal; -using System.Threading.Tasks; -using Azure.Core.TestFramework; -using ClientModel.Tests.Mocks; -using NUnit.Framework; - -namespace System.ClientModel.Tests.Convenience; - -// TODO: rename test file -public class AsyncServerSentEventJsonDataEnumeratorTests -{ - [Test] - public async Task EnumeratesSingleEvents() - { - MockPipelineResponse response = new(); - response.SetContent(_mockContent); - - async Task getResultAsync() - { - // TODO: simulate async correctly - await Task.Delay(0); - return ClientResult.FromResponse(response); - } - - AsyncSseValueResultCollection models = new(getResultAsync); - - int i = 0; - await foreach (MockJsonModel model in models) - { - Assert.AreEqual(model.IntValue, i); - Assert.AreEqual(model.StringValue, i.ToString()); - - i++; - } - - Assert.AreEqual(i, 3); - } - - // TODO: Add tests for dispose and handling cancellation token - // TODO: later, add tests for varying the _doneToken value. - // TODO: tests for infinite stream -- no terminal event; how to show it won't stop? - - #region Helpers - - private readonly string _mockContent = """ - event: event.0 - data: { "IntValue": 0, "StringValue": "0" } - - event: event.1 - data: { "IntValue": 1, "StringValue": "1" } - - event: event.2 - data: { "IntValue": 2, "StringValue": "2" } - - event: done - data: [DONE] - - """; - - #endregion -} diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncSseResultCollectionTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncSseResultCollectionTests.cs new file mode 100644 index 0000000000000..45cbae5029ae7 --- /dev/null +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncSseResultCollectionTests.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ClientModel.Internal; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core.TestFramework; +using ClientModel.Tests.Mocks; +using NUnit.Framework; + +namespace System.ClientModel.Tests.Convenience; + +public class AsyncSseResultCollectionTests +{ + [Test] + public async Task ValueCollectionEnumeratesValues() + { + MockPipelineResponse response = new(); + response.SetContent(_mockContent); + + async Task getResultAsync() + { + // TODO: simulate async correctly + await Task.Delay(0); + return ClientResult.FromResponse(response); + } + + AsyncSseValueResultCollection models = new(getResultAsync); + + int i = 0; + await foreach (MockJsonModel model in models) + { + Assert.AreEqual(model.IntValue, i); + Assert.AreEqual(model.StringValue, i.ToString()); + + i++; + } + + Assert.AreEqual(i, 3); + } + + [Test] + public void ValueCollectionThrowsIfCancelled() + { + MockPipelineResponse response = new(); + response.SetContent(_mockContent); + + async Task getResultAsync() + { + // TODO: simulate async correctly + await Task.Delay(0); + return ClientResult.FromResponse(response); + } + + AsyncSseValueResultCollection models = new(getResultAsync); + + CancellationToken token = new(true); + + Assert.ThrowsAsync(async () => + { + await foreach (MockJsonModel model in models.WithCancellation(token)) + { + } + }); + } + + [Test] + public async Task BinaryDataCollectionEnumeratesData() + { + MockPipelineResponse response = new(); + response.SetContent(_mockContent); + + AsyncSseBinaryDataResultCollection results = new(response); + + int i = 0; + await foreach (BinaryData result in results) + { + MockJsonModel model = result.ToObjectFromJson(); + + Assert.AreEqual(model.IntValue, i); + Assert.AreEqual(model.StringValue, i.ToString()); + + i++; + } + + Assert.AreEqual(i, 3); + } + + [Test] + public void BinaryDataCollectionThrowsIfCancelled() + { + MockPipelineResponse response = new(); + response.SetContent(_mockContent); + + AsyncSseBinaryDataResultCollection results = new(response); + + CancellationToken token = new(true); + + Assert.ThrowsAsync(async () => + { + await foreach (BinaryData result in results.WithCancellation(token)) + { + } + }); + } + + // TODO: Add tests for dispose and handling cancellation token + // TODO: later, add tests for varying the _doneToken value. + // TODO: tests for infinite stream -- no terminal event; how to show it won't stop? + + #region Helpers + + private readonly string _mockContent = """ + event: event.0 + data: { "IntValue": 0, "StringValue": "0" } + + event: event.1 + data: { "IntValue": 1, "StringValue": "1" } + + event: event.2 + data: { "IntValue": 2, "StringValue": "2" } + + event: done + data: [DONE] + + """; + + #endregion +} diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs index 173afbfba46b2..9bd2196592ae5 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs @@ -4,6 +4,7 @@ using System.ClientModel.Internal; using System.Collections.Generic; using System.IO; +using System.Threading; using System.Threading.Tasks; using NUnit.Framework; @@ -39,6 +40,18 @@ public async Task GetsEventsFromStream() // TODO: Question - should this include the "done" event? Probably yes? } + [Test] + public void ThrowsIfCancelled() + { + CancellationToken token = new(true); + + using Stream contentStream = BinaryData.FromString(_mockContent).ToStream(); + using ServerSentEventReader reader = new(contentStream); + + Assert.ThrowsAsync(async () + => await reader.TryGetNextEventAsync(token)); + } + #region Helpers private string _mockContent = """ diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/StreamingClientResultTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/StreamingClientResultTests.cs deleted file mode 100644 index 730208d5689cb..0000000000000 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/StreamingClientResultTests.cs +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -//using System.ClientModel.Internal; -//using System.Threading.Tasks; -//using Azure.Core.TestFramework; -//using ClientModel.Tests.Mocks; -//using NUnit.Framework; - -//namespace System.ClientModel.Tests.Convenience; - -//public class StreamingClientResultTests -//{ -// [Test] -// public async Task EnumeratesModelValues() -// { -// MockPipelineResponse response = new(); -// response.SetContent(_mockContent); -// var results = StreamingClientResult.Create(response); - -// int i = 0; -// await foreach (MockJsonModel model in results) -// { -// Assert.AreEqual(model.IntValue, i); -// Assert.AreEqual(model.StringValue, i.ToString()); - -// i++; -// } - -// Assert.AreEqual(i, 3); -// } - -// // TODO: Add tests for dispose and handling cancellation token -// // TODO: later, add tests for varying the _doneToken value. - -// #region Helpers - -// private readonly string _mockContent = """ -// event: event.0 -// data: { "IntValue": 0, "StringValue": "0" } - -// event: event.1 -// data: { "IntValue": 1, "StringValue": "1" } - -// event: event.2 -// data: { "IntValue": 2, "StringValue": "2" } - -// event: done -// data: [DONE] - -// """; - -// #endregion -//} From a04f5438197bbd06d22b63080ac66e9d2b8f30a2 Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Mon, 6 May 2024 16:15:09 -0700 Subject: [PATCH 23/45] remove factory method taking Func and provide example of layering convenience implementation in a way that postpones sending the request --- .../api/System.ClientModel.net6.0.cs | 1 - .../api/System.ClientModel.netstandard2.0.cs | 1 - .../Convenience/AsyncResultCollectionOfT.cs | 12 +- .../SSE/AsyncSseValueResultCollection.cs | 121 ------------------ .../ClientResultCollectionTests.cs | 116 +++++++---------- .../tests/TestFramework/Mocks/MockClient.cs | 77 ++++++++++- .../SSE/AsyncSseResultCollectionTests.cs | 52 -------- 7 files changed, 120 insertions(+), 260 deletions(-) delete mode 100644 sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseValueResultCollection.cs diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs index dea67751158ff..f78622255b5c1 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs @@ -12,7 +12,6 @@ public abstract partial class AsyncResultCollection : System.ClientModel.Clie protected internal AsyncResultCollection() : base (default(System.ClientModel.Primitives.PipelineResponse)) { } protected internal AsyncResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } public static System.ClientModel.AsyncResultCollection Create(System.ClientModel.Primitives.PipelineResponse response) { throw null; } - public static System.ClientModel.AsyncResultCollection Create(System.Func> getResultAsync) where TValue : System.ClientModel.Primitives.IJsonModel { throw null; } public abstract System.Collections.Generic.IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); } public abstract partial class BinaryContent : System.IDisposable diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs index 500e3176c02cd..c321d7886e2bf 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs @@ -12,7 +12,6 @@ public abstract partial class AsyncResultCollection : System.ClientModel.Clie protected internal AsyncResultCollection() : base (default(System.ClientModel.Primitives.PipelineResponse)) { } protected internal AsyncResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } public static System.ClientModel.AsyncResultCollection Create(System.ClientModel.Primitives.PipelineResponse response) { throw null; } - public static System.ClientModel.AsyncResultCollection Create(System.Func> getResultAsync) where TValue : System.ClientModel.Primitives.IJsonModel { throw null; } public abstract System.Collections.Generic.IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); } public abstract partial class BinaryContent : System.IDisposable diff --git a/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs b/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs index 76db2b089bf16..79e908e0c912a 100644 --- a/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs +++ b/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs @@ -5,7 +5,6 @@ using System.ClientModel.Primitives; using System.Collections.Generic; using System.Threading; -using System.Threading.Tasks; namespace System.ClientModel; @@ -26,18 +25,9 @@ protected internal AsyncResultCollection(PipelineResponse response) : base(respo { } - #region Factory methods + #region Factory method - // TODO: Consider signature: `public static ClientResultCollection Create(PipelineResponse response) where TValue : IJsonModel` ? // TODO: terminal event can be a model type as well ... are we happy using string for now and adding an overload if needed later? - public static AsyncResultCollection Create(Func> getResultAsync) - where TValue : IJsonModel - { - Argument.AssertNotNull(getResultAsync, nameof(getResultAsync)); - - return new AsyncSseValueResultCollection(getResultAsync); - } - public static AsyncResultCollection Create(PipelineResponse response) { Argument.AssertNotNull(response, nameof(response)); diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseValueResultCollection.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseValueResultCollection.cs deleted file mode 100644 index 188cd1d772c9f..0000000000000 --- a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseValueResultCollection.cs +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System.ClientModel.Primitives; -using System.Collections.Generic; -using System.Diagnostics; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; - -namespace System.ClientModel.Internal; - -internal class AsyncSseValueResultCollection : AsyncResultCollection - where T : IJsonModel -{ - private readonly Func> _getResultAsync; - - public AsyncSseValueResultCollection(Func> getResultAsync) : base() - { - Debug.Assert(getResultAsync is not null); - - _getResultAsync = getResultAsync!; - } - - public override IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) - { - return new AsyncSseValueEnumerator(_getResultAsync, this, cancellationToken); - } - - private sealed class AsyncSseValueEnumerator : IAsyncEnumerator - { - private readonly Func> _getResultAsync; - private readonly AsyncSseValueResultCollection _enumerable; - private readonly CancellationToken _cancellationToken; - - private AsyncServerSentEventEnumerator? _events; - private T? _current; - - private bool _started; - - // TODO: is null supression the correct pattern here? - public T Current { get => _current!; } - - public AsyncSseValueEnumerator(Func> getResultAsync, AsyncSseValueResultCollection enumerable, CancellationToken cancellationToken) - { - Debug.Assert(getResultAsync is not null); - Debug.Assert(enumerable is not null); - - _getResultAsync = getResultAsync!; - _enumerable = enumerable!; - _cancellationToken = cancellationToken; - } - - public async ValueTask MoveNextAsync() - { - if (_events is null && _started) - { - throw new ObjectDisposedException(nameof(AsyncSseValueEnumerator)); - } - - _cancellationToken.ThrowIfCancellationRequested(); - - // TODO: refactor for clarity - // Lazily initialize - if (_events is null) - { - // TODO: show that cancellation token can be handled as part of - // the closure and doesn't need to be passed? Or ... no? - ClientResult result = await _getResultAsync().ConfigureAwait(false); - PipelineResponse response = result.GetRawResponse(); - _enumerable.SetRawResponse(response); - - if (response.ContentStream is null) - { - throw new ArgumentException("Unable to create result from response with null ContentStream", nameof(response)); - } - - _events = new(response.ContentStream, _cancellationToken); - _started = true; - } - - if (await _events.MoveNextAsync().ConfigureAwait(false)) - { - using JsonDocument eventDocument = JsonDocument.Parse(_events.Current.Data); - BinaryData eventData = BinaryData.FromObjectAsJson(eventDocument.RootElement); - T? jsonData = ModelReaderWriter.Read(eventData); - - // TODO: should we stop iterating if we can't deserialize? - if (jsonData is null) - { - _current = default; - return false; - } - - if (jsonData is T singleInstanceData) - { - _current = singleInstanceData; - return true; - } - } - - return false; - } - - public async ValueTask DisposeAsync() - { - await DisposeAsyncCore().ConfigureAwait(false); - - GC.SuppressFinalize(this); - } - - private async ValueTask DisposeAsyncCore() - { - if (_events is not null) - { - await _events.DisposeAsync().ConfigureAwait(false); - _events = null; - } - } - } -} diff --git a/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs b/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs index 3a4a4875cc6fc..8fe465fd76e21 100644 --- a/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs +++ b/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs @@ -16,52 +16,18 @@ public ClientResultCollectionTests(bool isAsync) : base(isAsync) { } - // TODO: add tests for protocol methods we need to pass parameters to - // to show this method signature works with closures as expected. - - [Test] - public async Task CreatesAsyncResultCollection() - { - MockPipelineResponse response = new(); - response.SetContent("[DONE]"); - - async Task getResultAsync() - { - // TODO: simulate async correctly - await Task.Delay(0); - return ClientResult.FromResponse(response); - } - - var results = AsyncResultCollection.Create(getResultAsync); - - bool empty = true; - await foreach (MockJsonModel result in results) - { - empty = false; - } - - Assert.IsNotNull(results); - Assert.AreEqual(results.GetRawResponse(), response); - Assert.IsTrue(empty); - } - [Test] - public async Task CanEnumerateModelValues() + public async Task CanEnumerateBinaryDataValues() { MockPipelineResponse response = new(); response.SetContent(_mockContent); - - Func> getResultAsync = async () => - { - await Task.Delay(0); - return ClientResult.FromResponse(response); - }; - - var results = AsyncResultCollection.Create(getResultAsync); + AsyncResultCollection results = AsyncResultCollection.Create(response); int i = 0; - await foreach (MockJsonModel model in results) + await foreach (BinaryData result in results) { + MockJsonModel model = result.ToObjectFromJson(); + Assert.AreEqual(model.IntValue, i); Assert.AreEqual(model.StringValue, i.ToString()); @@ -72,24 +38,22 @@ public async Task CanEnumerateModelValues() } [Test] - public async Task CanEnumerateBinaryDataValues() + public void BinaryDataCollectionThrowsIfCancelled() { MockPipelineResponse response = new(); response.SetContent(_mockContent); - AsyncResultCollection results = AsyncResultCollection.Create(response); - - int i = 0; - await foreach (BinaryData result in results) - { - MockJsonModel model = result.ToObjectFromJson(); - Assert.AreEqual(model.IntValue, i); - Assert.AreEqual(model.StringValue, i.ToString()); + var results = AsyncResultCollection.Create(response); - i++; - } + // Set it to `cancelled: true` to validate functionality. + CancellationToken token = new(true); - Assert.AreEqual(i, 3); + Assert.ThrowsAsync(async () => + { + await foreach (BinaryData result in results.WithCancellation(token)) + { + } + }); } [Test] @@ -114,44 +78,52 @@ public async Task CanDelaySendingRequest() } [Test] - public void ModelCollectionThrowsIfCancelled() + public async Task CreatesAsyncResultCollection() { - MockPipelineResponse response = new(); - response.SetContent(_mockContent); + MockClient client = new(); + AsyncResultCollection models = client.GetModelsStreamingAsync("[DONE]"); - Func> getResultAsync = async () => + bool empty = true; + await foreach (MockJsonModel model in models) { - await Task.Delay(0); - return ClientResult.FromResponse(response); - }; + empty = false; + } - var results = AsyncResultCollection.Create(getResultAsync); + Assert.IsNotNull(models); + Assert.AreEqual(models.GetRawResponse().Content.ToString(), "[DONE]"); + Assert.IsTrue(empty); + } - // Set it to `cancelled: true` to validate functionality. - CancellationToken token = new(true); + [Test] + public async Task CanEnumerateModelValues() + { + MockClient client = new MockClient(); + AsyncResultCollection models = client.GetModelsStreamingAsync(_mockContent); - Assert.ThrowsAsync(async () => + int i = 0; + await foreach (MockJsonModel model in models) { - await foreach (MockJsonModel model in results.WithCancellation(token)) - { - } - }); + Assert.AreEqual(model.IntValue, i); + Assert.AreEqual(model.StringValue, i.ToString()); + + i++; + } + + Assert.AreEqual(i, 3); } [Test] - public void BinaryDataCollectionThrowsIfCancelled() + public void ModelCollectionThrowsIfCancelled() { - MockPipelineResponse response = new(); - response.SetContent(_mockContent); - - var results = AsyncResultCollection.Create(response); + MockClient client = new MockClient(); + AsyncResultCollection models = client.GetModelsStreamingAsync(_mockContent); // Set it to `cancelled: true` to validate functionality. CancellationToken token = new(true); Assert.ThrowsAsync(async () => { - await foreach (BinaryData result in results.WithCancellation(token)) + await foreach (MockJsonModel model in models.WithCancellation(token)) { } }); diff --git a/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs b/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs index 20e244fd82f36..7e3f0b5cc9a3e 100644 --- a/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs +++ b/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs @@ -5,6 +5,7 @@ using System.ClientModel; using System.ClientModel.Primitives; using System.Collections.Generic; +using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using Azure.Core.TestFramework; @@ -97,8 +98,80 @@ async Task getResultAsync() return _protocolMethod(_content, /*options:*/ default); } - AsyncResultCollection enumerable = Create(getResultAsync); - return enumerable.GetAsyncEnumerator(cancellationToken); + return new AsyncMockJsonModelEnumerator(getResultAsync, this, cancellationToken); + } + + private sealed class AsyncMockJsonModelEnumerator : IAsyncEnumerator + { + private readonly Func> _getResultAsync; + private readonly MockJsonModelCollection _enumerable; + private readonly CancellationToken _cancellationToken; + + private IAsyncEnumerator? _events; + private MockJsonModel? _current; + + private bool _started; + + public AsyncMockJsonModelEnumerator(Func> getResultAsync, MockJsonModelCollection enumerable, CancellationToken cancellationToken) + { + Debug.Assert(getResultAsync is not null); + Debug.Assert(enumerable is not null); + + _getResultAsync = getResultAsync!; + _enumerable = enumerable!; + _cancellationToken = cancellationToken; + } + + MockJsonModel IAsyncEnumerator.Current + => _current!; + + async ValueTask IAsyncEnumerator.MoveNextAsync() + { + if (_events is null && _started) + { + throw new ObjectDisposedException(nameof(AsyncMockJsonModelEnumerator)); + } + + _cancellationToken.ThrowIfCancellationRequested(); + + // TODO: refactor for clarity + // Lazily initialize + if (_events is null) + { + ClientResult result = await _getResultAsync().ConfigureAwait(false); + PipelineResponse response = result.GetRawResponse(); + _enumerable.SetRawResponse(response); + + if (response.ContentStream is null) + { + throw new ArgumentException("Unable to create result from response with null ContentStream", nameof(response)); + } + + AsyncResultCollection events = Create(response); + _events = events.GetAsyncEnumerator(); + _started = true; + } + + if (await _events.MoveNextAsync().ConfigureAwait(false)) + { + MockJsonModel? model = ModelReaderWriter.Read(_events.Current); + + // TODO: should we stop iterating if we can't deserialize? + if (model is null) + { + _current = default; + return false; + } + + _current = model; + return true; + } + + _current = default; + return false; + } + + ValueTask IAsyncDisposable.DisposeAsync() => new(); } } } diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncSseResultCollectionTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncSseResultCollectionTests.cs index 45cbae5029ae7..944eb4a59c8cc 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncSseResultCollectionTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncSseResultCollectionTests.cs @@ -12,58 +12,6 @@ namespace System.ClientModel.Tests.Convenience; public class AsyncSseResultCollectionTests { - [Test] - public async Task ValueCollectionEnumeratesValues() - { - MockPipelineResponse response = new(); - response.SetContent(_mockContent); - - async Task getResultAsync() - { - // TODO: simulate async correctly - await Task.Delay(0); - return ClientResult.FromResponse(response); - } - - AsyncSseValueResultCollection models = new(getResultAsync); - - int i = 0; - await foreach (MockJsonModel model in models) - { - Assert.AreEqual(model.IntValue, i); - Assert.AreEqual(model.StringValue, i.ToString()); - - i++; - } - - Assert.AreEqual(i, 3); - } - - [Test] - public void ValueCollectionThrowsIfCancelled() - { - MockPipelineResponse response = new(); - response.SetContent(_mockContent); - - async Task getResultAsync() - { - // TODO: simulate async correctly - await Task.Delay(0); - return ClientResult.FromResponse(response); - } - - AsyncSseValueResultCollection models = new(getResultAsync); - - CancellationToken token = new(true); - - Assert.ThrowsAsync(async () => - { - await foreach (MockJsonModel model in models.WithCancellation(token)) - { - } - }); - } - [Test] public async Task BinaryDataCollectionEnumeratesData() { From d6d43755224d1b43489807b3bd82c108078fff88 Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Mon, 6 May 2024 17:48:17 -0700 Subject: [PATCH 24/45] rename internal types and WIP adding reader tests --- .../Convenience/AsyncResultCollectionOfT.cs | 2 +- ...tion.cs => AsyncSseDataEventCollection.cs} | 12 ++-- .../src/Internal/SSE/ServerSentEventReader.cs | 7 ++- ...cs => AsyncSseDataEventCollectionTests.cs} | 6 +- .../SSE/ServerSentEventReaderTests.cs | 57 +++++++++++++++++++ 5 files changed, 71 insertions(+), 13 deletions(-) rename sdk/core/System.ClientModel/src/Internal/SSE/{AsyncSseBinaryDataResultCollection.cs => AsyncSseDataEventCollection.cs} (82%) rename sdk/core/System.ClientModel/tests/internal/Convenience/SSE/{AsyncSseResultCollectionTests.cs => AsyncSseDataEventCollectionTests.cs} (91%) diff --git a/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs b/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs index 79e908e0c912a..8823ed6012ab7 100644 --- a/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs +++ b/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs @@ -37,7 +37,7 @@ public static AsyncResultCollection Create(PipelineResponse response throw new ArgumentException("Unable to create result collection from PipelineResponse with null ContentStream", nameof(response)); } - return new AsyncSseBinaryDataResultCollection(response); + return new AsyncSseDataEventCollection(response); } #endregion diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseBinaryDataResultCollection.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseDataEventCollection.cs similarity index 82% rename from sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseBinaryDataResultCollection.cs rename to sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseDataEventCollection.cs index 13ad4cf72a336..0745b1b0c6c4d 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseBinaryDataResultCollection.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseDataEventCollection.cs @@ -11,11 +11,11 @@ namespace System.ClientModel.Internal; -internal class AsyncSseBinaryDataResultCollection : AsyncResultCollection +internal class AsyncSseDataEventCollection : AsyncResultCollection { // Note: this one doesn't delay sending the request because it's used // with protocol methods. - public AsyncSseBinaryDataResultCollection(PipelineResponse response) : base(response) + public AsyncSseDataEventCollection(PipelineResponse response) : base(response) { Argument.AssertNotNull(response, nameof(response)); } @@ -28,10 +28,10 @@ public override IAsyncEnumerator GetAsyncEnumerator(CancellationToke // AsyncResultCollection.Create factory method. Debug.Assert(response.ContentStream is not null); - return new AsyncSseDataEnumerator(response.ContentStream!, cancellationToken); + return new AsyncSseDataEventEnumerator(response.ContentStream!, cancellationToken); } - private sealed class AsyncSseDataEnumerator : IAsyncEnumerator + private sealed class AsyncSseDataEventEnumerator : IAsyncEnumerator { private AsyncServerSentEventEnumerator? _events; private BinaryData? _current; @@ -39,7 +39,7 @@ private sealed class AsyncSseDataEnumerator : IAsyncEnumerator // TODO: is null supression the correct pattern here? public BinaryData Current { get => _current!; } - public AsyncSseDataEnumerator(Stream contentStream, CancellationToken cancellationToken) + public AsyncSseDataEventEnumerator(Stream contentStream, CancellationToken cancellationToken) { Debug.Assert(contentStream is not null); @@ -50,7 +50,7 @@ public async ValueTask MoveNextAsync() { if (_events is null) { - throw new ObjectDisposedException(nameof(AsyncSseDataEnumerator)); + throw new ObjectDisposedException(nameof(AsyncSseDataEventEnumerator)); } if (await _events.MoveNextAsync().ConfigureAwait(false)) diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs index be96148d7be1d..3e91becfcbc42 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs @@ -65,7 +65,7 @@ public ServerSentEventReader(Stream stream) } else { - // Otherwise, process the the field + value and accumulate it for the next dispatched event + // Otherwise, process the field + value and accumulate it for the next dispatched event fields ??= new(); fields.Add(new ServerSentEventField(line)); } @@ -96,7 +96,8 @@ public ServerSentEventReader(Stream stream) if (line == null) { - // A null line indicates end of input + // A null line indicates end of input. + // Per the SSE spec, "Once the end of the file is reached, any pending data must be discarded." return null; } else if (line.Length == 0) @@ -115,7 +116,7 @@ public ServerSentEventReader(Stream stream) } else { - // Otherwise, process the the field + value and accumulate it for the next dispatched event + // Otherwise, process the field + value and accumulate it for the next dispatched event fields ??= new(); fields.Add(new ServerSentEventField(line)); } diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncSseResultCollectionTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncSseDataEventCollectionTests.cs similarity index 91% rename from sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncSseResultCollectionTests.cs rename to sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncSseDataEventCollectionTests.cs index 944eb4a59c8cc..4d21248565e14 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncSseResultCollectionTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncSseDataEventCollectionTests.cs @@ -10,7 +10,7 @@ namespace System.ClientModel.Tests.Convenience; -public class AsyncSseResultCollectionTests +public class AsyncSseDataEventCollectionTests { [Test] public async Task BinaryDataCollectionEnumeratesData() @@ -18,7 +18,7 @@ public async Task BinaryDataCollectionEnumeratesData() MockPipelineResponse response = new(); response.SetContent(_mockContent); - AsyncSseBinaryDataResultCollection results = new(response); + AsyncSseDataEventCollection results = new(response); int i = 0; await foreach (BinaryData result in results) @@ -40,7 +40,7 @@ public void BinaryDataCollectionThrowsIfCancelled() MockPipelineResponse response = new(); response.SetContent(_mockContent); - AsyncSseBinaryDataResultCollection results = new(response); + AsyncSseDataEventCollection results = new(response); CancellationToken token = new(true); diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs index 9bd2196592ae5..d3ed614868b18 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs @@ -40,6 +40,63 @@ public async Task GetsEventsFromStream() // TODO: Question - should this include the "done" event? Probably yes? } + [Test] + public async Task HandlesNullLine() + { + Stream contentStream = BinaryData.FromString(string.Empty).ToStream(); + using ServerSentEventReader reader = new(contentStream); + + ServerSentEvent? ssEvent = await reader.TryGetNextEventAsync(); + Assert.IsNull(ssEvent); + } + + [Test] + public async Task DiscardsCommentLine() + { + Stream contentStream = BinaryData.FromString(": comment").ToStream(); + using ServerSentEventReader reader = new(contentStream); + + ServerSentEvent? ssEvent = await reader.TryGetNextEventAsync(); + Assert.IsNull(ssEvent); + } + + //[Test] + //public async Task HandlesIgnoreLine() + //{ + // Stream contentStream = BinaryData.FromString(""" + // ignore: done + + // """).ToStream(); + // using ServerSentEventReader reader = new(contentStream); + + // ServerSentEvent? sse = await reader.TryGetNextEventAsync(); + + // Assert.IsNotNull(sse); + // Assert.IsTrue(sse.Value.EventName.Span.SequenceEqual($"done".AsSpan())); + // Assert.IsTrue(sse.Value.Data.Span.SequenceEqual($"[DONE]".AsSpan())); + // Assert.AreEqual(sse.Value.LastEventId.Length, 0); + // Assert.IsNull(sse.Value.ReconnectionTime); + //} + + //[Test] + //public async Task HandlesDoneEvent() + //{ + // Stream contentStream = BinaryData.FromString(""" + // event: done + // data: [DONE] + + // """).ToStream(); + // using ServerSentEventReader reader = new(contentStream); + + // ServerSentEvent? sse = await reader.TryGetNextEventAsync(); + + // Assert.IsNotNull(sse); + // Assert.IsTrue(sse.Value.EventName.Span.SequenceEqual($"done".AsSpan())); + // Assert.IsTrue(sse.Value.Data.Span.SequenceEqual($"[DONE]".AsSpan())); + // Assert.AreEqual(sse.Value.LastEventId.Length, 0); + // Assert.IsNull(sse.Value.ReconnectionTime); + //} + [Test] public void ThrowsIfCancelled() { From 6fa96cbd443e37cc63aadc16afbeae926ba292ce Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Tue, 7 May 2024 09:00:02 -0700 Subject: [PATCH 25/45] nits --- sdk/core/System.ClientModel/src/Pipeline/ClientRetryPolicy.cs | 2 +- .../internal/Convenience/SSE/ServerSentEventReaderTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/core/System.ClientModel/src/Pipeline/ClientRetryPolicy.cs b/sdk/core/System.ClientModel/src/Pipeline/ClientRetryPolicy.cs index 6a66860c7c37f..3d297f4c83dda 100644 --- a/sdk/core/System.ClientModel/src/Pipeline/ClientRetryPolicy.cs +++ b/sdk/core/System.ClientModel/src/Pipeline/ClientRetryPolicy.cs @@ -305,7 +305,7 @@ protected virtual void Wait(TimeSpan time, CancellationToken cancellationToken) { if (cancellationToken.WaitHandle.WaitOne(time)) { - cancellationToken.ThrowIfCancellationRequested(); + CancellationHelper.ThrowIfCancellationRequested(cancellationToken); } } } diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs index d3ed614868b18..57d2346efa06c 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs @@ -65,7 +65,7 @@ public async Task DiscardsCommentLine() //{ // Stream contentStream = BinaryData.FromString(""" // ignore: done - + // // """).ToStream(); // using ServerSentEventReader reader = new(contentStream); From 1fbd03888fe01082a1b5f181bc83ce83a2e3e61e Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Tue, 7 May 2024 11:31:18 -0700 Subject: [PATCH 26/45] parameterize terminal event; TBD to provide virtual method on collection type --- .../api/System.ClientModel.net6.0.cs | 2 +- .../api/System.ClientModel.netstandard2.0.cs | 2 +- .../Convenience/AsyncResultCollectionOfT.cs | 8 +- .../SSE/AsyncServerSentEventEnumerator.cs | 10 +-- .../SSE/AsyncSseDataEventCollection.cs | 14 ++-- .../ClientResultCollectionTests.cs | 5 +- .../tests/TestFramework/Mocks/MockClient.cs | 2 +- .../AsyncServerSentEventEnumeratorTests.cs | 5 +- .../SSE/AsyncSseDataEventCollectionTests.cs | 5 +- .../SSE/ServerSentEventReaderTests.cs | 78 +++++++++---------- 10 files changed, 66 insertions(+), 65 deletions(-) diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs index f78622255b5c1..76111005c6f5b 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs @@ -11,7 +11,7 @@ public abstract partial class AsyncResultCollection : System.ClientModel.Clie { protected internal AsyncResultCollection() : base (default(System.ClientModel.Primitives.PipelineResponse)) { } protected internal AsyncResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } - public static System.ClientModel.AsyncResultCollection Create(System.ClientModel.Primitives.PipelineResponse response) { throw null; } + public static System.ClientModel.AsyncResultCollection Create(System.ClientModel.Primitives.PipelineResponse response, string terminalEvent) { throw null; } public abstract System.Collections.Generic.IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); } public abstract partial class BinaryContent : System.IDisposable diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs index c321d7886e2bf..a3169fa5bad7d 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs @@ -11,7 +11,7 @@ public abstract partial class AsyncResultCollection : System.ClientModel.Clie { protected internal AsyncResultCollection() : base (default(System.ClientModel.Primitives.PipelineResponse)) { } protected internal AsyncResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } - public static System.ClientModel.AsyncResultCollection Create(System.ClientModel.Primitives.PipelineResponse response) { throw null; } + public static System.ClientModel.AsyncResultCollection Create(System.ClientModel.Primitives.PipelineResponse response, string terminalEvent) { throw null; } public abstract System.Collections.Generic.IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); } public abstract partial class BinaryContent : System.IDisposable diff --git a/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs b/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs index 8823ed6012ab7..adb19a08c56f3 100644 --- a/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs +++ b/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs @@ -25,10 +25,8 @@ protected internal AsyncResultCollection(PipelineResponse response) : base(respo { } - #region Factory method - // TODO: terminal event can be a model type as well ... are we happy using string for now and adding an overload if needed later? - public static AsyncResultCollection Create(PipelineResponse response) + public static AsyncResultCollection Create(PipelineResponse response, string terminalEvent) { Argument.AssertNotNull(response, nameof(response)); @@ -37,11 +35,9 @@ public static AsyncResultCollection Create(PipelineResponse response throw new ArgumentException("Unable to create result collection from PipelineResponse with null ContentStream", nameof(response)); } - return new AsyncSseDataEventCollection(response); + return new AsyncSseDataEventCollection(response, terminalEvent); } - #endregion - public abstract IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default); } #pragma warning restore CS1591 // public XML comments diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs index 8db7c1e5f62e9..b74a199e60604 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs @@ -10,18 +10,18 @@ namespace System.ClientModel.Internal; internal sealed class AsyncServerSentEventEnumerator : IAsyncEnumerator { - // TODO: make this configurable per coming from TypeSpec - private static readonly ReadOnlyMemory _doneToken = "[DONE]".AsMemory(); - + private readonly ReadOnlyMemory _terminalEvent; private readonly CancellationToken _cancellationToken; + private ServerSentEventReader? _reader; public ServerSentEvent Current { get; private set; } - public AsyncServerSentEventEnumerator(Stream contentStream, CancellationToken cancellationToken = default) + public AsyncServerSentEventEnumerator(Stream contentStream, string terminalEvent, CancellationToken cancellationToken = default) { _reader = new(contentStream); _cancellationToken = cancellationToken; + _terminalEvent = terminalEvent.AsMemory(); } public async ValueTask MoveNextAsync() @@ -34,7 +34,7 @@ public async ValueTask MoveNextAsync() ServerSentEvent? nextEvent = await _reader.TryGetNextEventAsync(_cancellationToken).ConfigureAwait(false); if (nextEvent.HasValue) { - if (nextEvent.Value.Data.Span.SequenceEqual(_doneToken.Span)) + if (nextEvent.Value.Data.Span.SequenceEqual(_terminalEvent.Span)) { return false; } diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseDataEventCollection.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseDataEventCollection.cs index 0745b1b0c6c4d..9ec060125bc7d 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseDataEventCollection.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseDataEventCollection.cs @@ -13,11 +13,13 @@ namespace System.ClientModel.Internal; internal class AsyncSseDataEventCollection : AsyncResultCollection { - // Note: this one doesn't delay sending the request because it's used - // with protocol methods. - public AsyncSseDataEventCollection(PipelineResponse response) : base(response) + private readonly string _terminalEvent; + + public AsyncSseDataEventCollection(PipelineResponse response, string terminalEvent) : base(response) { Argument.AssertNotNull(response, nameof(response)); + + _terminalEvent = terminalEvent; } public override IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) @@ -28,7 +30,7 @@ public override IAsyncEnumerator GetAsyncEnumerator(CancellationToke // AsyncResultCollection.Create factory method. Debug.Assert(response.ContentStream is not null); - return new AsyncSseDataEventEnumerator(response.ContentStream!, cancellationToken); + return new AsyncSseDataEventEnumerator(response.ContentStream!, _terminalEvent, cancellationToken); } private sealed class AsyncSseDataEventEnumerator : IAsyncEnumerator @@ -39,11 +41,11 @@ private sealed class AsyncSseDataEventEnumerator : IAsyncEnumerator // TODO: is null supression the correct pattern here? public BinaryData Current { get => _current!; } - public AsyncSseDataEventEnumerator(Stream contentStream, CancellationToken cancellationToken) + public AsyncSseDataEventEnumerator(Stream contentStream, string terminalEvent, CancellationToken cancellationToken) { Debug.Assert(contentStream is not null); - _events = new(contentStream!, cancellationToken); + _events = new(contentStream!, terminalEvent, cancellationToken); } public async ValueTask MoveNextAsync() diff --git a/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs b/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs index 8fe465fd76e21..303c2068629e4 100644 --- a/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs +++ b/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs @@ -21,7 +21,7 @@ public async Task CanEnumerateBinaryDataValues() { MockPipelineResponse response = new(); response.SetContent(_mockContent); - AsyncResultCollection results = AsyncResultCollection.Create(response); + AsyncResultCollection results = AsyncResultCollection.Create(response, "[DONE]"); int i = 0; await foreach (BinaryData result in results) @@ -43,7 +43,7 @@ public void BinaryDataCollectionThrowsIfCancelled() MockPipelineResponse response = new(); response.SetContent(_mockContent); - var results = AsyncResultCollection.Create(response); + var results = AsyncResultCollection.Create(response, "[DONE]"); // Set it to `cancelled: true` to validate functionality. CancellationToken token = new(true); @@ -144,6 +144,7 @@ public void ModelCollectionThrowsIfCancelled() event: done data: [DONE] + """; #endregion diff --git a/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs b/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs index 7e3f0b5cc9a3e..41c576277c0b5 100644 --- a/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs +++ b/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs @@ -147,7 +147,7 @@ async ValueTask IAsyncEnumerator.MoveNextAsync() throw new ArgumentException("Unable to create result from response with null ContentStream", nameof(response)); } - AsyncResultCollection events = Create(response); + AsyncResultCollection events = Create(response, "[DONE]"); _events = events.GetAsyncEnumerator(); _started = true; } diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumeratorTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumeratorTests.cs index 85a8297458596..3d1ae45e0ddee 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumeratorTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumeratorTests.cs @@ -15,7 +15,7 @@ public class AsyncServerSentEventEnumeratorTests public async Task EnumeratesEvents() { using Stream contentStream = BinaryData.FromString(_mockContent).ToStream(); - AsyncServerSentEventEnumerator enumerator = new(contentStream); + AsyncServerSentEventEnumerator enumerator = new(contentStream, "[DONE]"); int i = 0; while (await enumerator.MoveNextAsync()) @@ -37,7 +37,7 @@ public void ThrowsIfCancelled() CancellationToken token = new(true); using Stream contentStream = BinaryData.FromString(_mockContent).ToStream(); - AsyncServerSentEventEnumerator enumerator = new(contentStream, token); + AsyncServerSentEventEnumerator enumerator = new(contentStream, "[DONE]", token); Assert.ThrowsAsync(async () => await enumerator.MoveNextAsync()); } @@ -60,6 +60,7 @@ public void ThrowsIfCancelled() event: done data: [DONE] + """; #endregion diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncSseDataEventCollectionTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncSseDataEventCollectionTests.cs index 4d21248565e14..3db6139899b00 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncSseDataEventCollectionTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncSseDataEventCollectionTests.cs @@ -18,7 +18,7 @@ public async Task BinaryDataCollectionEnumeratesData() MockPipelineResponse response = new(); response.SetContent(_mockContent); - AsyncSseDataEventCollection results = new(response); + AsyncSseDataEventCollection results = new(response, "[DONE]"); int i = 0; await foreach (BinaryData result in results) @@ -40,7 +40,7 @@ public void BinaryDataCollectionThrowsIfCancelled() MockPipelineResponse response = new(); response.SetContent(_mockContent); - AsyncSseDataEventCollection results = new(response); + AsyncSseDataEventCollection results = new(response, "[DONE]"); CancellationToken token = new(true); @@ -71,6 +71,7 @@ public void BinaryDataCollectionThrowsIfCancelled() event: done data: [DONE] + """; #endregion diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs index 57d2346efa06c..967e1c3312050 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs @@ -28,16 +28,17 @@ public async Task GetsEventsFromStream() ssEvent = await reader.TryGetNextEventAsync(); } - Assert.AreEqual(events.Count, 3); + Assert.AreEqual(events.Count, 4); - for (int i = 0; i < events.Count; i++) + for (int i = 0; i < 3; i++) { ServerSentEvent sse = events[i]; Assert.IsTrue(sse.EventName.Span.SequenceEqual($"event.{i}".AsSpan())); Assert.IsTrue(sse.Data.Span.SequenceEqual($"{{ \"id\": \"{i}\", \"object\": {i} }}".AsSpan())); } - // TODO: Question - should this include the "done" event? Probably yes? + Assert.IsTrue(events[3].EventName.Span.SequenceEqual("done".AsSpan())); + Assert.IsTrue(events[3].Data.Span.SequenceEqual("[DONE]".AsSpan())); } [Test] @@ -60,42 +61,39 @@ public async Task DiscardsCommentLine() Assert.IsNull(ssEvent); } - //[Test] - //public async Task HandlesIgnoreLine() - //{ - // Stream contentStream = BinaryData.FromString(""" - // ignore: done - // - // """).ToStream(); - // using ServerSentEventReader reader = new(contentStream); - - // ServerSentEvent? sse = await reader.TryGetNextEventAsync(); - - // Assert.IsNotNull(sse); - // Assert.IsTrue(sse.Value.EventName.Span.SequenceEqual($"done".AsSpan())); - // Assert.IsTrue(sse.Value.Data.Span.SequenceEqual($"[DONE]".AsSpan())); - // Assert.AreEqual(sse.Value.LastEventId.Length, 0); - // Assert.IsNull(sse.Value.ReconnectionTime); - //} - - //[Test] - //public async Task HandlesDoneEvent() - //{ - // Stream contentStream = BinaryData.FromString(""" - // event: done - // data: [DONE] - - // """).ToStream(); - // using ServerSentEventReader reader = new(contentStream); - - // ServerSentEvent? sse = await reader.TryGetNextEventAsync(); - - // Assert.IsNotNull(sse); - // Assert.IsTrue(sse.Value.EventName.Span.SequenceEqual($"done".AsSpan())); - // Assert.IsTrue(sse.Value.Data.Span.SequenceEqual($"[DONE]".AsSpan())); - // Assert.AreEqual(sse.Value.LastEventId.Length, 0); - // Assert.IsNull(sse.Value.ReconnectionTime); - //} + [Test] + public async Task HandlesIgnoreLine() + { + Stream contentStream = BinaryData.FromString(""" + ignore: noop + + + """).ToStream(); + using ServerSentEventReader reader = new(contentStream); + + ServerSentEvent? sse = await reader.TryGetNextEventAsync(); + + Assert.IsNotNull(sse); + Assert.AreEqual(sse.Value.EventName.Length, 0); + Assert.AreEqual(sse.Value.Data.Length, 0); + Assert.AreEqual(sse.Value.LastEventId.Length, 0); + Assert.IsNull(sse.Value.ReconnectionTime); + } + + [Test] + public async Task HandlesDoneEvent() + { + Stream contentStream = BinaryData.FromString("event: done\ndata: [DONE]\n\n").ToStream(); + using ServerSentEventReader reader = new(contentStream); + + ServerSentEvent? sse = await reader.TryGetNextEventAsync(); + + Assert.IsNotNull(sse); + Assert.IsTrue(sse.Value.EventName.Span.SequenceEqual($"done".AsSpan())); + Assert.IsTrue(sse.Value.Data.Span.SequenceEqual($"[DONE]".AsSpan())); + Assert.AreEqual(sse.Value.LastEventId.Length, 0); + Assert.IsNull(sse.Value.ReconnectionTime); + } [Test] public void ThrowsIfCancelled() @@ -111,6 +109,7 @@ public void ThrowsIfCancelled() #region Helpers + // Note: raw string literal quirk removes \n from final line. private string _mockContent = """ event: event.0 data: { "id": "0", "object": 0 } @@ -124,6 +123,7 @@ public void ThrowsIfCancelled() event: done data: [DONE] + """; #endregion From f342162a434cc29c60be63059fb855a95be093c9 Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Tue, 7 May 2024 14:43:24 -0700 Subject: [PATCH 27/45] WIP: nits --- .../Convenience/AsyncResultCollectionOfT.cs | 4 +++- .../SSE/AsyncServerSentEventEnumerator.cs | 7 +++++-- .../SSE/AsyncSseDataEventCollection.cs | 1 - .../SSE/ServerSentEventReaderTests.cs | 21 +++++++++++++++++++ 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs b/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs index adb19a08c56f3..59f631d6cb618 100644 --- a/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs +++ b/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs @@ -25,7 +25,6 @@ protected internal AsyncResultCollection(PipelineResponse response) : base(respo { } - // TODO: terminal event can be a model type as well ... are we happy using string for now and adding an overload if needed later? public static AsyncResultCollection Create(PipelineResponse response, string terminalEvent) { Argument.AssertNotNull(response, nameof(response)); @@ -39,5 +38,8 @@ public static AsyncResultCollection Create(PipelineResponse response } public abstract IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default); + + // TODO: what input does it take? + //public virtual bool CloseStream() { } } #pragma warning restore CS1591 // public XML comments diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs index b74a199e60604..3e1ecc0e6f778 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs @@ -14,8 +14,9 @@ internal sealed class AsyncServerSentEventEnumerator : IAsyncEnumerator _current; public AsyncServerSentEventEnumerator(Stream contentStream, string terminalEvent, CancellationToken cancellationToken = default) { @@ -32,14 +33,16 @@ public async ValueTask MoveNextAsync() } ServerSentEvent? nextEvent = await _reader.TryGetNextEventAsync(_cancellationToken).ConfigureAwait(false); + if (nextEvent.HasValue) { if (nextEvent.Value.Data.Span.SequenceEqual(_terminalEvent.Span)) { + _current = default; return false; } - Current = nextEvent.Value; + _current = nextEvent.Value; return true; } diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseDataEventCollection.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseDataEventCollection.cs index 9ec060125bc7d..dca4b613d860e 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseDataEventCollection.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseDataEventCollection.cs @@ -38,7 +38,6 @@ private sealed class AsyncSseDataEventEnumerator : IAsyncEnumerator private AsyncServerSentEventEnumerator? _events; private BinaryData? _current; - // TODO: is null supression the correct pattern here? public BinaryData Current { get => _current!; } public AsyncSseDataEventEnumerator(Stream contentStream, string terminalEvent, CancellationToken cancellationToken) diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs index 967e1c3312050..1ce1b8461b817 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs @@ -95,6 +95,27 @@ public async Task HandlesDoneEvent() Assert.IsNull(sse.Value.ReconnectionTime); } + [Test] + public async Task ConcatenatesDataLines() + { + Stream contentStream = BinaryData.FromString(""" + data: YHOO + data: +2 + data: 10 + + + """).ToStream(); + using ServerSentEventReader reader = new(contentStream); + + ServerSentEvent? sse = await reader.TryGetNextEventAsync(); + + Assert.IsNotNull(sse); + Assert.AreEqual(sse.Value.EventName.Length, 0); + Assert.IsTrue(sse.Value.Data.Span.SequenceEqual("YHOO\n+2\n10".AsSpan())); + Assert.AreEqual(sse.Value.LastEventId.Length, 0); + Assert.IsNull(sse.Value.ReconnectionTime); + } + [Test] public void ThrowsIfCancelled() { From 4ac87a24797128b06ea8f5e24b09089e4eb09be7 Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Tue, 7 May 2024 17:19:09 -0700 Subject: [PATCH 28/45] WIP: added concatenation of data lines per SSE spec --- .../src/Internal/SSE/ServerSentEvent.cs | 63 +++++++++---------- .../AsyncServerSentEventEnumeratorTests.cs | 35 ++++++++++- .../SSE/ServerSentEventReaderTests.cs | 8 +-- 3 files changed, 68 insertions(+), 38 deletions(-) diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs index 91e355c1b279c..8809abdb98bc3 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs @@ -2,13 +2,14 @@ // Licensed under the MIT License. using System.Collections.Generic; -using System.Text; namespace System.ClientModel.Internal; // SSE specification: https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream internal readonly struct ServerSentEvent { + private const char LF = '\n'; + // Gets the value of the SSE "event type" buffer, used to distinguish between event kinds. public ReadOnlyMemory EventName { get; } // Gets the value of the SSE "data" buffer, which holds the payload of the server-sent event. @@ -18,53 +19,51 @@ internal readonly struct ServerSentEvent // If present, gets the defined "retry" value for the event, which represents the delay before reconnecting. public TimeSpan? ReconnectionTime { get; } - private readonly IReadOnlyList _fields; - private readonly string? _multiLineData; - internal ServerSentEvent(IReadOnlyList fields) { - _fields = fields; - StringBuilder? multiLineDataBuilder = null; - for (int i = 0; i < _fields.Count; i++) + int dataLength = 0; + foreach (ServerSentEventField field in fields) { - ReadOnlyMemory fieldValue = _fields[i].Value; - switch (_fields[i].FieldType) + switch (field.FieldType) { case ServerSentEventFieldKind.Event: - EventName = fieldValue; + EventName = field.Value; break; case ServerSentEventFieldKind.Data: - { - if (multiLineDataBuilder != null) - { - multiLineDataBuilder.Append(fieldValue); - } - else if (Data.IsEmpty) - { - Data = fieldValue; - } - else - { - multiLineDataBuilder ??= new(); - multiLineDataBuilder.Append(fieldValue); - Data = null; - } - break; - } + dataLength += field.Value.Length + 1; + break; case ServerSentEventFieldKind.Id: - LastEventId = fieldValue; + LastEventId = field.Value; break; case ServerSentEventFieldKind.Retry: - ReconnectionTime = int.TryParse(fieldValue.ToString(), out int retry) ? TimeSpan.FromMilliseconds(retry) : null; +#if NETSTANDARD2_0 + ReconnectionTime = int.TryParse(field.Value.ToString(), out int retry) ? TimeSpan.FromMilliseconds(retry) : null; +#else + ReconnectionTime = int.TryParse(field.Value.Span, out int retry) ? TimeSpan.FromMilliseconds(retry) : null; +#endif break; default: break; } - if (multiLineDataBuilder != null) + } + + if (dataLength > 0) + { + Memory buffer = new(new char[dataLength]); + + int curr = 0; + + foreach (ServerSentEventField field in fields) { - _multiLineData = multiLineDataBuilder.ToString(); - Data = _multiLineData.AsMemory(); + if (field.FieldType == ServerSentEventFieldKind.Data) + { + field.Value.Span.CopyTo(buffer.Span.Slice(curr)); + buffer.Span[curr + field.Value.Length] = LF; + curr += field.Value.Length + 1; + } } + + Data = buffer; } } } diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumeratorTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumeratorTests.cs index 3d1ae45e0ddee..66c7b8b6a6b6a 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumeratorTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumeratorTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.ClientModel.Internal; +using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -42,8 +43,38 @@ public void ThrowsIfCancelled() Assert.ThrowsAsync(async () => await enumerator.MoveNextAsync()); } - // TODO: Add tests for dispose and handling cancellation token - // TODO: later, add tests for varying the _doneToken value. + [Test] + public async Task StopsOnStringBasedTerminalEvent() + { + string mockContent = """ + event: event.0 + data: 0 + + event: stop + data: ~stop~ + + event: event.1 + data: 1 + + + """; + + using Stream contentStream = BinaryData.FromString(mockContent).ToStream(); + AsyncServerSentEventEnumerator enumerator = new(contentStream, "~stop~"); + + List events = new(); + + while (await enumerator.MoveNextAsync()) + { + events.Add(enumerator.Current); + } + + Assert.AreEqual(events.Count, 1); + Assert.IsTrue(events[0].EventName.Span.SequenceEqual("event.0".AsSpan())); + Assert.IsTrue(events[0].Data.Span.SequenceEqual("0".AsSpan())); + } + + // TODO: Add tests for dispose #region Helpers diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs index 1ce1b8461b817..6df7d942a081a 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs @@ -83,14 +83,14 @@ public async Task HandlesIgnoreLine() [Test] public async Task HandlesDoneEvent() { - Stream contentStream = BinaryData.FromString("event: done\ndata: [DONE]\n\n").ToStream(); + Stream contentStream = BinaryData.FromString("event: stop\ndata: ~stop~\n\n").ToStream(); using ServerSentEventReader reader = new(contentStream); ServerSentEvent? sse = await reader.TryGetNextEventAsync(); Assert.IsNotNull(sse); - Assert.IsTrue(sse.Value.EventName.Span.SequenceEqual($"done".AsSpan())); - Assert.IsTrue(sse.Value.Data.Span.SequenceEqual($"[DONE]".AsSpan())); + Assert.IsTrue(sse.Value.EventName.Span.SequenceEqual("stop".AsSpan())); + Assert.IsTrue(sse.Value.Data.Span.SequenceEqual("~stop~".AsSpan())); Assert.AreEqual(sse.Value.LastEventId.Length, 0); Assert.IsNull(sse.Value.ReconnectionTime); } @@ -111,7 +111,7 @@ public async Task ConcatenatesDataLines() Assert.IsNotNull(sse); Assert.AreEqual(sse.Value.EventName.Length, 0); - Assert.IsTrue(sse.Value.Data.Span.SequenceEqual("YHOO\n+2\n10".AsSpan())); + Assert.IsTrue(sse.Value.Data.Span.SequenceEqual("YHOO\n+2\n10\n".AsSpan())); Assert.AreEqual(sse.Value.LastEventId.Length, 0); Assert.IsNull(sse.Value.ReconnectionTime); } From dab0852ca6451cc62cfa4ceda66401db0acdd3d6 Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Tue, 7 May 2024 18:20:05 -0700 Subject: [PATCH 29/45] updates and bug fixes --- .../src/Internal/SSE/ServerSentEvent.cs | 15 ++++++++++++++- .../Convenience/SSE/ServerSentEventReaderTests.cs | 7 ++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs index 8809abdb98bc3..572ad2534688e 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs @@ -63,7 +63,20 @@ internal ServerSentEvent(IReadOnlyList fields) } } - Data = buffer; + // remove trailing LF. + Data = buffer.Slice(0, buffer.Length - 1); + } + + if (Data.Length == 0) + { + // Per spec, if data buffer is empty, set event type buffer to empty. + EventName = ReadOnlyMemory.Empty; + } + + if (EventName.Length == 0) + { + // Per spec, if event type buffer is empty, set event.type to "message". + EventName = "message".ToCharArray(); } } } diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs index 6df7d942a081a..06b7e2ebc0a53 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs @@ -74,7 +74,7 @@ public async Task HandlesIgnoreLine() ServerSentEvent? sse = await reader.TryGetNextEventAsync(); Assert.IsNotNull(sse); - Assert.AreEqual(sse.Value.EventName.Length, 0); + Assert.IsTrue(sse.Value.EventName.Span.SequenceEqual("message".AsSpan())); Assert.AreEqual(sse.Value.Data.Length, 0); Assert.AreEqual(sse.Value.LastEventId.Length, 0); Assert.IsNull(sse.Value.ReconnectionTime); @@ -99,6 +99,7 @@ public async Task HandlesDoneEvent() public async Task ConcatenatesDataLines() { Stream contentStream = BinaryData.FromString(""" + event: event data: YHOO data: +2 data: 10 @@ -110,8 +111,8 @@ public async Task ConcatenatesDataLines() ServerSentEvent? sse = await reader.TryGetNextEventAsync(); Assert.IsNotNull(sse); - Assert.AreEqual(sse.Value.EventName.Length, 0); - Assert.IsTrue(sse.Value.Data.Span.SequenceEqual("YHOO\n+2\n10\n".AsSpan())); + Assert.IsTrue(sse.Value.EventName.Span.SequenceEqual("event".AsSpan())); + Assert.IsTrue(sse.Value.Data.Span.SequenceEqual("YHOO\n+2\n10".AsSpan())); Assert.AreEqual(sse.Value.LastEventId.Length, 0); Assert.IsNull(sse.Value.ReconnectionTime); } From 1941f2c6b2216ce4a972ab3d34d61cf108841132 Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Tue, 7 May 2024 19:01:01 -0700 Subject: [PATCH 30/45] add tests and update per SSE spec --- .../src/Internal/SSE/ServerSentEvent.cs | 83 +++++++++-------- .../src/Internal/SSE/ServerSentEventReader.cs | 89 ++++++++++++++----- .../SSE/ServerSentEventReaderTests.cs | 45 ++++++++-- .../Convenience/SSE/ServerSentEventTests.cs | 30 +++---- 4 files changed, 160 insertions(+), 87 deletions(-) diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs index 572ad2534688e..130274b78a2d9 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs @@ -2,9 +2,21 @@ // Licensed under the MIT License. using System.Collections.Generic; +using System.Diagnostics; namespace System.ClientModel.Internal; +internal struct PendingEvent +{ + private List? _dataFields; + + public int DataLength { get; set; } + public List DataFields => _dataFields ??= new(); + public ServerSentEventField? EventNameField { get; set; } + public ServerSentEventField? IdField { get; set; } + public ServerSentEventField? RetryField { get; set; } +} + // SSE specification: https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream internal readonly struct ServerSentEvent { @@ -19,60 +31,45 @@ internal readonly struct ServerSentEvent // If present, gets the defined "retry" value for the event, which represents the delay before reconnecting. public TimeSpan? ReconnectionTime { get; } - internal ServerSentEvent(IReadOnlyList fields) + internal ServerSentEvent(PendingEvent pending) { - int dataLength = 0; - foreach (ServerSentEventField field in fields) + if (pending.EventNameField.HasValue) + { + EventName = pending.EventNameField.Value.Value; + } + + if (pending.IdField.HasValue) + { + LastEventId = pending.IdField.Value.Value; + } + + if (pending.RetryField.HasValue) { - switch (field.FieldType) - { - case ServerSentEventFieldKind.Event: - EventName = field.Value; - break; - case ServerSentEventFieldKind.Data: - dataLength += field.Value.Length + 1; - break; - case ServerSentEventFieldKind.Id: - LastEventId = field.Value; - break; - case ServerSentEventFieldKind.Retry: #if NETSTANDARD2_0 - ReconnectionTime = int.TryParse(field.Value.ToString(), out int retry) ? TimeSpan.FromMilliseconds(retry) : null; + ReconnectionTime = int.TryParse(pending.RetryField.Value.Value.ToString(), out int retry) ? TimeSpan.FromMilliseconds(retry) : null; #else - ReconnectionTime = int.TryParse(field.Value.Span, out int retry) ? TimeSpan.FromMilliseconds(retry) : null; + ReconnectionTime = int.TryParse(pending.RetryField.Value.Value.Span, out int retry) ? TimeSpan.FromMilliseconds(retry) : null; #endif - break; - default: - break; - } } - if (dataLength > 0) - { - Memory buffer = new(new char[dataLength]); - - int curr = 0; - - foreach (ServerSentEventField field in fields) - { - if (field.FieldType == ServerSentEventFieldKind.Data) - { - field.Value.Span.CopyTo(buffer.Span.Slice(curr)); - buffer.Span[curr + field.Value.Length] = LF; - curr += field.Value.Length + 1; - } - } - - // remove trailing LF. - Data = buffer.Slice(0, buffer.Length - 1); - } + Debug.Assert(pending.DataLength > 0); - if (Data.Length == 0) + Memory buffer = new(new char[pending.DataLength]); + + int curr = 0; + + foreach (ServerSentEventField field in pending.DataFields) { - // Per spec, if data buffer is empty, set event type buffer to empty. - EventName = ReadOnlyMemory.Empty; + Debug.Assert(field.FieldType == ServerSentEventFieldKind.Data); + + field.Value.Span.CopyTo(buffer.Span.Slice(curr)); + buffer.Span[curr + field.Value.Length] = LF; + curr += field.Value.Length + 1; } + // remove trailing LF. + Data = buffer.Slice(0, buffer.Length - 1); + if (EventName.Length == 0) { // Per spec, if event type buffer is empty, set event.type to "message". diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs index 3e91becfcbc42..319b58c5a72f6 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -15,6 +13,8 @@ internal sealed class ServerSentEventReader : IDisposable, IAsyncDisposable private Stream? _stream; private StreamReader? _reader; + public int? LastEventId { get; private set; } + public ServerSentEventReader(Stream stream) { _stream = stream; @@ -36,7 +36,7 @@ public ServerSentEventReader(Stream stream) throw new ObjectDisposedException(nameof(ServerSentEventReader)); } - List? fields = default; + PendingEvent pending = default; while (true) { cancellationToken.ThrowIfCancellationRequested(); @@ -51,12 +51,14 @@ public ServerSentEventReader(Stream stream) } else if (line.Length == 0) { - Debug.Assert(fields is not null); - - // An empty line should dispatch an event for pending accumulated fields - ServerSentEvent nextEvent = new(fields!); - fields = default; - return nextEvent; + if (pending.DataLength == 0) + { + // Per spec, if there's no data, don't dispatch an event. + pending = default; + continue; + } + + return new ServerSentEvent(pending); } else if (line[0] == ':') { @@ -65,9 +67,27 @@ public ServerSentEventReader(Stream stream) } else { - // Otherwise, process the field + value and accumulate it for the next dispatched event - fields ??= new(); - fields.Add(new ServerSentEventField(line)); + // Otherwise, process the field + value and accumulate it for the + // next dispatched event. + ServerSentEventField field = new(line); + switch (field.FieldType) + { + case ServerSentEventFieldKind.Event: + pending.EventNameField = field; + break; + case ServerSentEventFieldKind.Data: + pending.DataFields.Add(field); + break; + case ServerSentEventFieldKind.Id: + pending.IdField = field; + break; + case ServerSentEventFieldKind.Retry: + pending.RetryField = field; + break; + default: + // Ignore + break; + } } } } @@ -87,27 +107,29 @@ public ServerSentEventReader(Stream stream) throw new ObjectDisposedException(nameof(ServerSentEventReader)); } - List? fields = default; + PendingEvent pending = default; while (true) { cancellationToken.ThrowIfCancellationRequested(); + // TODO: Pass cancellationToken? string? line = await _reader.ReadLineAsync().ConfigureAwait(false); if (line == null) { - // A null line indicates end of input. - // Per the SSE spec, "Once the end of the file is reached, any pending data must be discarded." + // A null line indicates end of input return null; } else if (line.Length == 0) { - Debug.Assert(fields is not null); - - // An empty line should dispatch an event for pending accumulated fields - ServerSentEvent nextEvent = new(fields!); - fields = default; - return nextEvent; + if (pending.DataLength == 0) + { + // Per spec, if there's no data, don't dispatch an event. + pending = default; + continue; + } + + return new ServerSentEvent(pending); } else if (line[0] == ':') { @@ -116,9 +138,28 @@ public ServerSentEventReader(Stream stream) } else { - // Otherwise, process the field + value and accumulate it for the next dispatched event - fields ??= new(); - fields.Add(new ServerSentEventField(line)); + // Otherwise, process the field + value and accumulate it for the + // next dispatched event. + ServerSentEventField field = new(line); + switch (field.FieldType) + { + case ServerSentEventFieldKind.Event: + pending.EventNameField = field; + break; + case ServerSentEventFieldKind.Data: + pending.DataLength += field.Value.Length + 1; + pending.DataFields.Add(field); + break; + case ServerSentEventFieldKind.Id: + pending.IdField = field; + break; + case ServerSentEventFieldKind.Retry: + pending.RetryField = field; + break; + default: + // Ignore + break; + } } } } diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs index 06b7e2ebc0a53..dec8f07f38e1a 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs @@ -73,11 +73,7 @@ public async Task HandlesIgnoreLine() ServerSentEvent? sse = await reader.TryGetNextEventAsync(); - Assert.IsNotNull(sse); - Assert.IsTrue(sse.Value.EventName.Span.SequenceEqual("message".AsSpan())); - Assert.AreEqual(sse.Value.Data.Length, 0); - Assert.AreEqual(sse.Value.LastEventId.Length, 0); - Assert.IsNull(sse.Value.ReconnectionTime); + Assert.IsNull(sse); } [Test] @@ -117,6 +113,45 @@ public async Task ConcatenatesDataLines() Assert.IsNull(sse.Value.ReconnectionTime); } + [Test] + public async Task SecondSpecCase() + { + Stream contentStream = BinaryData.FromString(""" + : test stream + + data: first event + id: 1 + + data:second event + id + + data: third event + + + """).ToStream(); + using ServerSentEventReader reader = new(contentStream); + + List events = new(); + + ServerSentEvent? sse = await reader.TryGetNextEventAsync(); + while (sse is not null) + { + events.Add(sse.Value); + sse = await reader.TryGetNextEventAsync(); + } + + Assert.AreEqual(3, events.Count); + + Assert.IsTrue(events[0].Data.Span.SequenceEqual("first event".AsSpan())); + Assert.IsTrue(events[0].LastEventId.Span.SequenceEqual("1".AsSpan())); + + Assert.IsTrue(events[1].Data.Span.SequenceEqual("second event".AsSpan())); + Assert.AreEqual(events[1].LastEventId.Length, 0); + + Assert.IsTrue(events[2].Data.Span.SequenceEqual(" third event".AsSpan())); + Assert.AreEqual(events[2].LastEventId.Length, 0); + } + [Test] public void ThrowsIfCancelled() { diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventTests.cs index 63ecc7c6bb82c..4a40f99739496 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventTests.cs @@ -9,22 +9,22 @@ namespace System.ClientModel.Tests.Convenience; public class ServerSentEventTests { - [Test] - public void SetsPropertiesFromFields() - { - string eventLine = "event: event.name"; - string dataLine = """data: {"id":"a","object":"value"}"""; + //[Test] + //public void SetsPropertiesFromFields() + //{ + // string eventLine = "event: event.name"; + // string dataLine = """data: {"id":"a","object":"value"}"""; - List fields = new() { - new ServerSentEventField(eventLine), - new ServerSentEventField(dataLine) - }; + // List fields = new() { + // new ServerSentEventField(eventLine), + // new ServerSentEventField(dataLine) + // }; - ServerSentEvent ssEvent = new(fields); + // ServerSentEvent ssEvent = new(fields); - Assert.IsNull(ssEvent.ReconnectionTime); - Assert.IsTrue(ssEvent.EventName.Span.SequenceEqual("event.name".AsSpan())); - Assert.IsTrue(ssEvent.Data.Span.SequenceEqual("""{"id":"a","object":"value"}""".AsSpan())); - Assert.AreEqual(ssEvent.LastEventId.Length, 0); - } + // Assert.IsNull(ssEvent.ReconnectionTime); + // Assert.IsTrue(ssEvent.EventName.Span.SequenceEqual("event.name".AsSpan())); + // Assert.IsTrue(ssEvent.Data.Span.SequenceEqual("""{"id":"a","object":"value"}""".AsSpan())); + // Assert.AreEqual(ssEvent.LastEventId.Length, 0); + //} } From 58ac2a37418380671272f7acd6dc396e72ed37ec Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Wed, 8 May 2024 18:00:34 -0700 Subject: [PATCH 31/45] WIP: refactor to reuse field processing across sync and async methods --- .../src/Internal/SSE/ServerSentEventReader.cs | 121 +++++++----------- .../SSE/ServerSentEventReaderTests.cs | 59 ++++++++- 2 files changed, 107 insertions(+), 73 deletions(-) diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs index 319b58c5a72f6..3785b58cf68d2 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs @@ -44,50 +44,17 @@ public ServerSentEventReader(Stream stream) // TODO: Pass cancellationToken? string? line = _reader.ReadLine(); - if (line == null) + if (line is null) { // A null line indicates end of input return null; } - else if (line.Length == 0) - { - if (pending.DataLength == 0) - { - // Per spec, if there's no data, don't dispatch an event. - pending = default; - continue; - } - return new ServerSentEvent(pending); - } - else if (line[0] == ':') - { - // A line beginning with a colon is a comment and should be ignored - continue; - } - else + ProcessLine(line, ref pending, out bool dispatch); + + if (dispatch) { - // Otherwise, process the field + value and accumulate it for the - // next dispatched event. - ServerSentEventField field = new(line); - switch (field.FieldType) - { - case ServerSentEventFieldKind.Event: - pending.EventNameField = field; - break; - case ServerSentEventFieldKind.Data: - pending.DataFields.Add(field); - break; - case ServerSentEventFieldKind.Id: - pending.IdField = field; - break; - case ServerSentEventFieldKind.Retry: - pending.RetryField = field; - break; - default: - // Ignore - break; - } + return new ServerSentEvent(pending); } } } @@ -115,51 +82,61 @@ public ServerSentEventReader(Stream stream) // TODO: Pass cancellationToken? string? line = await _reader.ReadLineAsync().ConfigureAwait(false); - if (line == null) + if (line is null) { // A null line indicates end of input return null; } - else if (line.Length == 0) - { - if (pending.DataLength == 0) - { - // Per spec, if there's no data, don't dispatch an event. - pending = default; - continue; - } + ProcessLine(line, ref pending, out bool dispatch); + + if (dispatch) + { return new ServerSentEvent(pending); } - else if (line[0] == ':') + } + } + + private static void ProcessLine(string line, ref PendingEvent pending, out bool dispatch) + { + dispatch = false; + + if (line.Length == 0) + { + if (pending.DataLength == 0) { - // A line beginning with a colon is a comment and should be ignored - continue; + // Per spec, if there's no data, don't dispatch an event. + pending = default; } else { - // Otherwise, process the field + value and accumulate it for the - // next dispatched event. - ServerSentEventField field = new(line); - switch (field.FieldType) - { - case ServerSentEventFieldKind.Event: - pending.EventNameField = field; - break; - case ServerSentEventFieldKind.Data: - pending.DataLength += field.Value.Length + 1; - pending.DataFields.Add(field); - break; - case ServerSentEventFieldKind.Id: - pending.IdField = field; - break; - case ServerSentEventFieldKind.Retry: - pending.RetryField = field; - break; - default: - // Ignore - break; - } + dispatch = true; + } + } + else if (line[0] != ':') + { + // Not a comment line that spec says to ignore. + // Process the field + value and accumulate it for the + // next dispatched event. + ServerSentEventField field = new(line); + switch (field.FieldType) + { + case ServerSentEventFieldKind.Event: + pending.EventNameField = field; + break; + case ServerSentEventFieldKind.Data: + pending.DataLength += field.Value.Length + 1; + pending.DataFields.Add(field); + break; + case ServerSentEventFieldKind.Id: + pending.IdField = field; + break; + case ServerSentEventFieldKind.Retry: + pending.RetryField = field; + break; + default: + // Ignore + break; } } } diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs index dec8f07f38e1a..f0ad06dc39854 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs @@ -114,8 +114,9 @@ public async Task ConcatenatesDataLines() } [Test] - public async Task SecondSpecCase() + public async Task SecondTestCaseFromSpec() { + // See: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation Stream contentStream = BinaryData.FromString(""" : test stream @@ -152,6 +153,62 @@ public async Task SecondSpecCase() Assert.AreEqual(events[2].LastEventId.Length, 0); } + [Test] + public async Task ThirdSpecTestCase() + { + // See: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation + Stream contentStream = BinaryData.FromString(""" + data + + data + data + + data: + """).ToStream(); + using ServerSentEventReader reader = new(contentStream); + + List events = new(); + + ServerSentEvent? sse = await reader.TryGetNextEventAsync(); + while (sse is not null) + { + events.Add(sse.Value); + sse = await reader.TryGetNextEventAsync(); + } + + Assert.AreEqual(2, events.Count); + + Assert.AreEqual(0, events[0].Data.Length); + Assert.IsTrue(events[1].Data.Span.SequenceEqual("\n".AsSpan())); + } + + [Test] + public async Task FourthSpecTestCase() + { + // See: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation + Stream contentStream = BinaryData.FromString(""" + data:test + + data: test + + + """).ToStream(); + using ServerSentEventReader reader = new(contentStream); + + List events = new(); + + ServerSentEvent? sse = await reader.TryGetNextEventAsync(); + while (sse is not null) + { + events.Add(sse.Value); + sse = await reader.TryGetNextEventAsync(); + } + + Assert.AreEqual(2, events.Count); + + Assert.IsTrue(events[0].Data.Span.SequenceEqual(events[1].Data.Span)); + } + [Test] public void ThrowsIfCancelled() { From 88d5da13c10e9568b3e6a4d6c8e198a65f151d6b Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Thu, 9 May 2024 09:28:41 -0700 Subject: [PATCH 32/45] make look a little more like the BCL type proposal --- .../SSE/AsyncServerSentEventEnumerator.cs | 2 +- .../SSE/AsyncSseDataEventCollection.cs | 2 +- .../src/Internal/SSE/ServerSentEvent.cs | 72 ++----------------- .../src/Internal/SSE/ServerSentEventReader.cs | 65 ++++++++++++++++- .../AsyncServerSentEventEnumeratorTests.cs | 8 +-- .../SSE/ServerSentEventReaderTests.cs | 37 +++++----- .../Convenience/SSE/ServerSentEventTests.cs | 6 +- 7 files changed, 94 insertions(+), 98 deletions(-) diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs index 3e1ecc0e6f778..d01fb5b7f25c3 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs @@ -36,7 +36,7 @@ public async ValueTask MoveNextAsync() if (nextEvent.HasValue) { - if (nextEvent.Value.Data.Span.SequenceEqual(_terminalEvent.Span)) + if (nextEvent.Value.Data.AsSpan().SequenceEqual(_terminalEvent.Span)) { _current = default; return false; diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseDataEventCollection.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseDataEventCollection.cs index dca4b613d860e..2b336c6acbc33 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseDataEventCollection.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseDataEventCollection.cs @@ -56,7 +56,7 @@ public async ValueTask MoveNextAsync() if (await _events.MoveNextAsync().ConfigureAwait(false)) { - char[] chars = _events.Current.Data.ToArray(); + char[] chars = _events.Current.Data.ToCharArray(); byte[] bytes = Encoding.UTF8.GetBytes(chars); _current = new BinaryData(bytes); return true; diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs index 130274b78a2d9..28a59d577238c 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs @@ -1,79 +1,17 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using System.Collections.Generic; -using System.Diagnostics; - namespace System.ClientModel.Internal; -internal struct PendingEvent -{ - private List? _dataFields; - - public int DataLength { get; set; } - public List DataFields => _dataFields ??= new(); - public ServerSentEventField? EventNameField { get; set; } - public ServerSentEventField? IdField { get; set; } - public ServerSentEventField? RetryField { get; set; } -} - // SSE specification: https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream -internal readonly struct ServerSentEvent +internal struct ServerSentEvent { - private const char LF = '\n'; - // Gets the value of the SSE "event type" buffer, used to distinguish between event kinds. - public ReadOnlyMemory EventName { get; } + public string EventType { get; set; } // Gets the value of the SSE "data" buffer, which holds the payload of the server-sent event. - public ReadOnlyMemory Data { get; } + public string Data { get; set; } // Gets the value of the "last event ID" buffer, with which a user agent can reestablish a session. - public ReadOnlyMemory LastEventId { get; } + public string? Id { get; set; } // If present, gets the defined "retry" value for the event, which represents the delay before reconnecting. - public TimeSpan? ReconnectionTime { get; } - - internal ServerSentEvent(PendingEvent pending) - { - if (pending.EventNameField.HasValue) - { - EventName = pending.EventNameField.Value.Value; - } - - if (pending.IdField.HasValue) - { - LastEventId = pending.IdField.Value.Value; - } - - if (pending.RetryField.HasValue) - { -#if NETSTANDARD2_0 - ReconnectionTime = int.TryParse(pending.RetryField.Value.Value.ToString(), out int retry) ? TimeSpan.FromMilliseconds(retry) : null; -#else - ReconnectionTime = int.TryParse(pending.RetryField.Value.Value.Span, out int retry) ? TimeSpan.FromMilliseconds(retry) : null; -#endif - } - - Debug.Assert(pending.DataLength > 0); - - Memory buffer = new(new char[pending.DataLength]); - - int curr = 0; - - foreach (ServerSentEventField field in pending.DataFields) - { - Debug.Assert(field.FieldType == ServerSentEventFieldKind.Data); - - field.Value.Span.CopyTo(buffer.Span.Slice(curr)); - buffer.Span[curr + field.Value.Length] = LF; - curr += field.Value.Length + 1; - } - - // remove trailing LF. - Data = buffer.Slice(0, buffer.Length - 1); - - if (EventName.Length == 0) - { - // Per spec, if event type buffer is empty, set event.type to "message". - EventName = "message".ToCharArray(); - } - } + public TimeSpan? ReconnectionTime { get; set; } } diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs index 3785b58cf68d2..ebad77ca6149c 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -54,7 +56,7 @@ public ServerSentEventReader(Stream stream) if (dispatch) { - return new ServerSentEvent(pending); + return pending.ToEvent(); } } } @@ -92,7 +94,7 @@ public ServerSentEventReader(Stream stream) if (dispatch) { - return new ServerSentEvent(pending); + return pending.ToEvent(); } } } @@ -122,7 +124,7 @@ private static void ProcessLine(string line, ref PendingEvent pending, out bool switch (field.FieldType) { case ServerSentEventFieldKind.Event: - pending.EventNameField = field; + pending.EventTypeField = field; break; case ServerSentEventFieldKind.Data: pending.DataLength += field.Value.Length + 1; @@ -141,6 +143,63 @@ private static void ProcessLine(string line, ref PendingEvent pending, out bool } } + private struct PendingEvent + { + private const char LF = '\n'; + + private List? _dataFields; + + public int DataLength { get; set; } + public List DataFields => _dataFields ??= new(); + public ServerSentEventField? EventTypeField { get; set; } + public ServerSentEventField? IdField { get; set; } + public ServerSentEventField? RetryField { get; set; } + + public ServerSentEvent ToEvent() + { + ServerSentEvent item = default; + + // Per spec, if event type buffer is empty, set event.type to "message". + item.EventType = EventTypeField.HasValue ? + EventTypeField.Value.Value.ToString() : + "message"; + + if (IdField.HasValue && IdField.Value.Value.Length > 0) + { + item.Id = IdField.Value.Value.ToString(); + } + + if (RetryField.HasValue) + { +#if NETSTANDARD2_0 + item.ReconnectionTime = int.TryParse(RetryField.Value.Value.ToString(), out int retry) ? TimeSpan.FromMilliseconds(retry) : null; +#else + item.ReconnectionTime = int.TryParse(RetryField.Value.Value.Span, out int retry) ? TimeSpan.FromMilliseconds(retry) : null; +#endif + } + + Debug.Assert(DataLength > 0); + + Memory buffer = new(new char[DataLength]); + + int curr = 0; + + foreach (ServerSentEventField field in DataFields) + { + Debug.Assert(field.FieldType == ServerSentEventFieldKind.Data); + + field.Value.Span.CopyTo(buffer.Span.Slice(curr)); + buffer.Span[curr + field.Value.Length] = LF; + curr += field.Value.Length + 1; + } + + // Per spec, remove trailing LF + item.Data = buffer.Slice(0, buffer.Length - 1).ToString(); + + return item; + } + } + public void Dispose() { Dispose(disposing: true); diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumeratorTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumeratorTests.cs index 66c7b8b6a6b6a..c09341259c08e 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumeratorTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumeratorTests.cs @@ -23,8 +23,8 @@ public async Task EnumeratesEvents() { ServerSentEvent sse = enumerator.Current; - Assert.IsTrue(sse.EventName.Span.SequenceEqual($"event.{i}".AsSpan())); - Assert.IsTrue(sse.Data.Span.SequenceEqual($"{{ \"id\": \"{i}\", \"object\": {i} }}".AsSpan())); + Assert.IsTrue(sse.EventType.AsSpan().SequenceEqual($"event.{i}".AsSpan())); + Assert.IsTrue(sse.Data.AsSpan().SequenceEqual($"{{ \"id\": \"{i}\", \"object\": {i} }}".AsSpan())); i++; } @@ -70,8 +70,8 @@ public async Task StopsOnStringBasedTerminalEvent() } Assert.AreEqual(events.Count, 1); - Assert.IsTrue(events[0].EventName.Span.SequenceEqual("event.0".AsSpan())); - Assert.IsTrue(events[0].Data.Span.SequenceEqual("0".AsSpan())); + Assert.IsTrue(events[0].EventType.AsSpan().SequenceEqual("event.0".AsSpan())); + Assert.IsTrue(events[0].Data.AsSpan().SequenceEqual("0".AsSpan())); } // TODO: Add tests for dispose diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs index f0ad06dc39854..2365653ffd0f3 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs @@ -33,12 +33,12 @@ public async Task GetsEventsFromStream() for (int i = 0; i < 3; i++) { ServerSentEvent sse = events[i]; - Assert.IsTrue(sse.EventName.Span.SequenceEqual($"event.{i}".AsSpan())); - Assert.IsTrue(sse.Data.Span.SequenceEqual($"{{ \"id\": \"{i}\", \"object\": {i} }}".AsSpan())); + Assert.IsTrue(sse.EventType.AsSpan().SequenceEqual($"event.{i}".AsSpan())); + Assert.IsTrue(sse.Data.AsSpan().SequenceEqual($"{{ \"id\": \"{i}\", \"object\": {i} }}".AsSpan())); } - Assert.IsTrue(events[3].EventName.Span.SequenceEqual("done".AsSpan())); - Assert.IsTrue(events[3].Data.Span.SequenceEqual("[DONE]".AsSpan())); + Assert.IsTrue(events[3].EventType.AsSpan().SequenceEqual("done".AsSpan())); + Assert.IsTrue(events[3].Data.AsSpan().SequenceEqual("[DONE]".AsSpan())); } [Test] @@ -85,9 +85,9 @@ public async Task HandlesDoneEvent() ServerSentEvent? sse = await reader.TryGetNextEventAsync(); Assert.IsNotNull(sse); - Assert.IsTrue(sse.Value.EventName.Span.SequenceEqual("stop".AsSpan())); - Assert.IsTrue(sse.Value.Data.Span.SequenceEqual("~stop~".AsSpan())); - Assert.AreEqual(sse.Value.LastEventId.Length, 0); + Assert.IsTrue(sse.Value.EventType.AsSpan().SequenceEqual("stop".AsSpan())); + Assert.IsTrue(sse.Value.Data.AsSpan().SequenceEqual("~stop~".AsSpan())); + Assert.IsNull(sse.Value.Id); Assert.IsNull(sse.Value.ReconnectionTime); } @@ -107,9 +107,9 @@ public async Task ConcatenatesDataLines() ServerSentEvent? sse = await reader.TryGetNextEventAsync(); Assert.IsNotNull(sse); - Assert.IsTrue(sse.Value.EventName.Span.SequenceEqual("event".AsSpan())); - Assert.IsTrue(sse.Value.Data.Span.SequenceEqual("YHOO\n+2\n10".AsSpan())); - Assert.AreEqual(sse.Value.LastEventId.Length, 0); + Assert.IsTrue(sse.Value.EventType.AsSpan().SequenceEqual("event".AsSpan())); + Assert.IsTrue(sse.Value.Data.AsSpan().SequenceEqual("YHOO\n+2\n10".AsSpan())); + Assert.IsNull(sse.Value.Id); Assert.IsNull(sse.Value.ReconnectionTime); } @@ -143,14 +143,14 @@ public async Task SecondTestCaseFromSpec() Assert.AreEqual(3, events.Count); - Assert.IsTrue(events[0].Data.Span.SequenceEqual("first event".AsSpan())); - Assert.IsTrue(events[0].LastEventId.Span.SequenceEqual("1".AsSpan())); + Assert.IsTrue(events[0].Data.AsSpan().SequenceEqual("first event".AsSpan())); + Assert.IsTrue(events[0].Id.AsSpan().SequenceEqual("1".AsSpan())); - Assert.IsTrue(events[1].Data.Span.SequenceEqual("second event".AsSpan())); - Assert.AreEqual(events[1].LastEventId.Length, 0); + Assert.IsTrue(events[1].Data.AsSpan().SequenceEqual("second event".AsSpan())); + Assert.IsNull(events[1].Id); - Assert.IsTrue(events[2].Data.Span.SequenceEqual(" third event".AsSpan())); - Assert.AreEqual(events[2].LastEventId.Length, 0); + Assert.IsTrue(events[2].Data.AsSpan().SequenceEqual(" third event".AsSpan())); + Assert.IsNull(events[2].Id); } [Test] @@ -179,7 +179,7 @@ public async Task ThirdSpecTestCase() Assert.AreEqual(2, events.Count); Assert.AreEqual(0, events[0].Data.Length); - Assert.IsTrue(events[1].Data.Span.SequenceEqual("\n".AsSpan())); + Assert.IsTrue(events[1].Data.AsSpan().SequenceEqual("\n".AsSpan())); } [Test] @@ -205,8 +205,7 @@ public async Task FourthSpecTestCase() } Assert.AreEqual(2, events.Count); - - Assert.IsTrue(events[0].Data.Span.SequenceEqual(events[1].Data.Span)); + Assert.AreEqual(events[0].Data, events[1].Data); } [Test] diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventTests.cs index 4a40f99739496..2ac586d87e069 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventTests.cs @@ -23,8 +23,8 @@ public class ServerSentEventTests // ServerSentEvent ssEvent = new(fields); // Assert.IsNull(ssEvent.ReconnectionTime); - // Assert.IsTrue(ssEvent.EventName.Span.SequenceEqual("event.name".AsSpan())); - // Assert.IsTrue(ssEvent.Data.Span.SequenceEqual("""{"id":"a","object":"value"}""".AsSpan())); - // Assert.AreEqual(ssEvent.LastEventId.Length, 0); + // Assert.IsTrue(ssEvent.EventType.AsSpan().SequenceEqual("event.name".AsSpan())); + // Assert.IsTrue(ssEvent.Data.AsSpan().SequenceEqual("""{"id":"a","object":"value"}""".AsSpan())); + // Assert.AreEqual(ssEvent.Id.Length, 0); //} } From d2776d8a7ebf1e55811033971f33e6cf5c373838 Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Thu, 9 May 2024 09:54:40 -0700 Subject: [PATCH 33/45] simplify field implementation a bit --- .../src/Internal/SSE/ServerSentEventField.cs | 56 +++++++------------ .../Internal/SSE/ServerSentEventFieldKind.cs | 3 +- .../SSE/ServerSentEventFieldTests.cs | 1 - 3 files changed, 22 insertions(+), 38 deletions(-) diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventField.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventField.cs index c484946fa37f0..de61cf5a4e3cf 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventField.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventField.cs @@ -6,60 +6,46 @@ namespace System.ClientModel.Internal; // SSE specification: https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream internal readonly struct ServerSentEventField { - public ServerSentEventFieldKind FieldType { get; } + private static readonly ReadOnlyMemory s_eventFieldName = "event".AsMemory(); + private static readonly ReadOnlyMemory s_dataFieldName = "data".AsMemory(); + private static readonly ReadOnlyMemory s_lastEventIdFieldName = "id".AsMemory(); + private static readonly ReadOnlyMemory s_retryFieldName = "retry".AsMemory(); - // TODO: we should not expose UTF16 publicly - public ReadOnlyMemory Value - { - get - { - if (_valueStartIndex >= _original.Length) - { - return ReadOnlyMemory.Empty; - } - else - { - return _original.AsMemory(_valueStartIndex); - } - } - } + public ServerSentEventFieldKind FieldType { get; } - private readonly string _original; - private readonly int _valueStartIndex; + // Note: don't expose UTF16 publicly + public ReadOnlyMemory Value { get; } internal ServerSentEventField(string line) { - _original = line; - int colonIndex = _original.AsSpan().IndexOf(':'); + int colonIndex = line.AsSpan().IndexOf(':'); + + ReadOnlyMemory fieldName = colonIndex < 0 ? + line.AsMemory() : + line.AsMemory(0, colonIndex); - ReadOnlyMemory fieldName = colonIndex < 0 ? _original.AsMemory(): _original.AsMemory(0, colonIndex); FieldType = fieldName.Span switch { var x when x.SequenceEqual(s_eventFieldName.Span) => ServerSentEventFieldKind.Event, var x when x.SequenceEqual(s_dataFieldName.Span) => ServerSentEventFieldKind.Data, var x when x.SequenceEqual(s_lastEventIdFieldName.Span) => ServerSentEventFieldKind.Id, var x when x.SequenceEqual(s_retryFieldName.Span) => ServerSentEventFieldKind.Retry, - _ => ServerSentEventFieldKind.Ignored, + _ => ServerSentEventFieldKind.Ignore, }; if (colonIndex < 0) { - _valueStartIndex = _original.Length; - } - else if (colonIndex + 1 < _original.Length && _original[colonIndex + 1] == ' ') - { - _valueStartIndex = colonIndex + 2; + Value = ReadOnlyMemory.Empty; } else { - _valueStartIndex = colonIndex + 1; + Value = line.AsMemory(colonIndex + 1); + + // Per spec, remove a leading space if present. + if (Value.Length > 0 && Value.Span[0] == ' ') + { + Value = Value.Slice(1); + } } } - - public override string ToString() => _original; - - private static readonly ReadOnlyMemory s_eventFieldName = "event".AsMemory(); - private static readonly ReadOnlyMemory s_dataFieldName = "data".AsMemory(); - private static readonly ReadOnlyMemory s_lastEventIdFieldName = "id".AsMemory(); - private static readonly ReadOnlyMemory s_retryFieldName = "retry".AsMemory(); } diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventFieldKind.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventFieldKind.cs index 48d1884a2a7a5..34a1cd22c67a2 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventFieldKind.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventFieldKind.cs @@ -6,10 +6,9 @@ namespace System.ClientModel.Internal; // SSE specification: https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream internal enum ServerSentEventFieldKind { - // TODO: zero value? + Ignore, Event, Data, Id, Retry, - Ignored } diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventFieldTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventFieldTests.cs index acac0f6056f9a..4b3b9db09d38d 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventFieldTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventFieldTests.cs @@ -14,7 +14,6 @@ public void ParsesEventField() string line = "event: event.name"; ServerSentEventField field = new(line); - Assert.AreEqual(field.ToString(), line); Assert.AreEqual(field.FieldType, ServerSentEventFieldKind.Event); Assert.IsTrue(field.Value.Span.SequenceEqual("event.name".AsSpan())); } From 27583772bcd0268d146f46d134d09785de88abfd Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Thu, 9 May 2024 10:53:08 -0700 Subject: [PATCH 34/45] cosmetic reworking of creating an event from a pending event --- .../src/Internal/SSE/ServerSentEvent.cs | 23 +++++++++++--- .../src/Internal/SSE/ServerSentEventReader.cs | 31 +++++++------------ 2 files changed, 29 insertions(+), 25 deletions(-) diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs index 28a59d577238c..4e950afabfdb6 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs @@ -4,14 +4,27 @@ namespace System.ClientModel.Internal; // SSE specification: https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream -internal struct ServerSentEvent +internal readonly struct ServerSentEvent { // Gets the value of the SSE "event type" buffer, used to distinguish between event kinds. - public string EventType { get; set; } + public string EventType { get; } + // Gets the value of the SSE "data" buffer, which holds the payload of the server-sent event. - public string Data { get; set; } + public string Data { get; } + // Gets the value of the "last event ID" buffer, with which a user agent can reestablish a session. - public string? Id { get; set; } + public string? Id { get; } + // If present, gets the defined "retry" value for the event, which represents the delay before reconnecting. - public TimeSpan? ReconnectionTime { get; set; } + public TimeSpan? ReconnectionTime { get; } + + public ServerSentEvent(string type, string data, string? id, string? retry) + { + EventType = type; + Data = data; + Id = id; + ReconnectionTime = retry is null ? + default : + int.TryParse(retry, out int time) ? TimeSpan.FromMilliseconds(time) : null; + } } diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs index ebad77ca6149c..5fff35faff83e 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs @@ -117,9 +117,9 @@ private static void ProcessLine(string line, ref PendingEvent pending, out bool } else if (line[0] != ':') { - // Not a comment line that spec says to ignore. - // Process the field + value and accumulate it for the - // next dispatched event. + // Per spec, ignore comment lines (i.e. that begin with ':'). + // If we got this far, process the field + value and accumulate + // it for the next dispatched event. ServerSentEventField field = new(line); switch (field.FieldType) { @@ -127,6 +127,7 @@ private static void ProcessLine(string line, ref PendingEvent pending, out bool pending.EventTypeField = field; break; case ServerSentEventFieldKind.Data: + // Per spec, we'll append \n when we concatenate the data lines. pending.DataLength += field.Value.Length + 1; pending.DataFields.Add(field); break; @@ -157,26 +158,16 @@ private struct PendingEvent public ServerSentEvent ToEvent() { - ServerSentEvent item = default; - // Per spec, if event type buffer is empty, set event.type to "message". - item.EventType = EventTypeField.HasValue ? + string type = EventTypeField.HasValue ? EventTypeField.Value.Value.ToString() : "message"; - if (IdField.HasValue && IdField.Value.Value.Length > 0) - { - item.Id = IdField.Value.Value.ToString(); - } + string? id = IdField.HasValue && IdField.Value.Value.Length > 0 ? + IdField.Value.Value.ToString() : default; - if (RetryField.HasValue) - { -#if NETSTANDARD2_0 - item.ReconnectionTime = int.TryParse(RetryField.Value.Value.ToString(), out int retry) ? TimeSpan.FromMilliseconds(retry) : null; -#else - item.ReconnectionTime = int.TryParse(RetryField.Value.Value.Span, out int retry) ? TimeSpan.FromMilliseconds(retry) : null; -#endif - } + string? retry = RetryField.HasValue && RetryField.Value.Value.Length > 0 ? + RetryField.Value.Value.ToString() : default; Debug.Assert(DataLength > 0); @@ -194,9 +185,9 @@ public ServerSentEvent ToEvent() } // Per spec, remove trailing LF - item.Data = buffer.Slice(0, buffer.Length - 1).ToString(); + string data = buffer.Slice(0, buffer.Length - 1).ToString(); - return item; + return new ServerSentEvent(type, data, id, retry); } } From eae0caaf2f5059c8958fa3d1debb9087bb510805 Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Thu, 9 May 2024 12:06:05 -0700 Subject: [PATCH 35/45] Remove factory method from public API; move MockSseClient to Tests.Internal to access internal SSE types --- .../Convenience/AsyncResultCollectionOfT.cs | 16 -- .../ClientResultCollectionTests.cs | 224 +++++++---------- .../tests/TestFramework/Mocks/MockClient.cs | 232 +++++++++--------- .../SSE/ClientResultCollectionTests.cs | 150 +++++++++++ .../TestFramework/Mocks/MockSseClient.cs | 141 +++++++++++ .../Mocks/MockSseClientExtensions.cs | 22 ++ 6 files changed, 521 insertions(+), 264 deletions(-) create mode 100644 sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ClientResultCollectionTests.cs create mode 100644 sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClient.cs create mode 100644 sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClientExtensions.cs diff --git a/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs b/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs index 59f631d6cb618..68bbbc9d17a51 100644 --- a/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs +++ b/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using System.ClientModel.Internal; using System.ClientModel.Primitives; using System.Collections.Generic; using System.Threading; @@ -25,21 +24,6 @@ protected internal AsyncResultCollection(PipelineResponse response) : base(respo { } - public static AsyncResultCollection Create(PipelineResponse response, string terminalEvent) - { - Argument.AssertNotNull(response, nameof(response)); - - if (response.ContentStream is null) - { - throw new ArgumentException("Unable to create result collection from PipelineResponse with null ContentStream", nameof(response)); - } - - return new AsyncSseDataEventCollection(response, terminalEvent); - } - public abstract IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default); - - // TODO: what input does it take? - //public virtual bool CloseStream() { } } #pragma warning restore CS1591 // public XML comments diff --git a/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs b/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs index 303c2068629e4..6ae1fd4df43be 100644 --- a/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs +++ b/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs @@ -16,136 +16,96 @@ public ClientResultCollectionTests(bool isAsync) : base(isAsync) { } - [Test] - public async Task CanEnumerateBinaryDataValues() - { - MockPipelineResponse response = new(); - response.SetContent(_mockContent); - AsyncResultCollection results = AsyncResultCollection.Create(response, "[DONE]"); - - int i = 0; - await foreach (BinaryData result in results) - { - MockJsonModel model = result.ToObjectFromJson(); - - Assert.AreEqual(model.IntValue, i); - Assert.AreEqual(model.StringValue, i.ToString()); - - i++; - } - - Assert.AreEqual(i, 3); - } - - [Test] - public void BinaryDataCollectionThrowsIfCancelled() - { - MockPipelineResponse response = new(); - response.SetContent(_mockContent); - - var results = AsyncResultCollection.Create(response, "[DONE]"); - - // Set it to `cancelled: true` to validate functionality. - CancellationToken token = new(true); - - Assert.ThrowsAsync(async () => - { - await foreach (BinaryData result in results.WithCancellation(token)) - { - } - }); - } - - [Test] - public async Task CanDelaySendingRequest() - { - MockClient client = new MockClient(); - AsyncResultCollection models = client.GetModelsStreamingAsync(_mockContent); - - Assert.IsFalse(client.StreamingProtocolMethodCalled); - - int i = 0; - await foreach (MockJsonModel model in models) - { - Assert.AreEqual(model.IntValue, i); - Assert.AreEqual(model.StringValue, i.ToString()); - - i++; - } - - Assert.AreEqual(i, 3); - Assert.IsTrue(client.StreamingProtocolMethodCalled); - } - - [Test] - public async Task CreatesAsyncResultCollection() - { - MockClient client = new(); - AsyncResultCollection models = client.GetModelsStreamingAsync("[DONE]"); - - bool empty = true; - await foreach (MockJsonModel model in models) - { - empty = false; - } - - Assert.IsNotNull(models); - Assert.AreEqual(models.GetRawResponse().Content.ToString(), "[DONE]"); - Assert.IsTrue(empty); - } - - [Test] - public async Task CanEnumerateModelValues() - { - MockClient client = new MockClient(); - AsyncResultCollection models = client.GetModelsStreamingAsync(_mockContent); - - int i = 0; - await foreach (MockJsonModel model in models) - { - Assert.AreEqual(model.IntValue, i); - Assert.AreEqual(model.StringValue, i.ToString()); - - i++; - } - - Assert.AreEqual(i, 3); - } - - [Test] - public void ModelCollectionThrowsIfCancelled() - { - MockClient client = new MockClient(); - AsyncResultCollection models = client.GetModelsStreamingAsync(_mockContent); - - // Set it to `cancelled: true` to validate functionality. - CancellationToken token = new(true); - - Assert.ThrowsAsync(async () => - { - await foreach (MockJsonModel model in models.WithCancellation(token)) - { - } - }); - } - - #region Helpers - - private readonly string _mockContent = """ - event: event.0 - data: { "IntValue": 0, "StringValue": "0" } - - event: event.1 - data: { "IntValue": 1, "StringValue": "1" } - - event: event.2 - data: { "IntValue": 2, "StringValue": "2" } - - event: done - data: [DONE] - - - """; - - #endregion + //[Test] + //public async Task CanDelaySendingRequest() + //{ + // MockClient client = new MockClient(); + // AsyncResultCollection models = client.GetModelsStreamingAsync(_mockContent); + + // Assert.IsFalse(client.StreamingProtocolMethodCalled); + + // int i = 0; + // await foreach (MockJsonModel model in models) + // { + // Assert.AreEqual(model.IntValue, i); + // Assert.AreEqual(model.StringValue, i.ToString()); + + // i++; + // } + + // Assert.AreEqual(i, 3); + // Assert.IsTrue(client.StreamingProtocolMethodCalled); + //} + + //[Test] + //public async Task CreatesAsyncResultCollection() + //{ + // MockClient client = new(); + // AsyncResultCollection models = client.GetModelsStreamingAsync("[DONE]"); + + // bool empty = true; + // await foreach (MockJsonModel model in models) + // { + // empty = false; + // } + + // Assert.IsNotNull(models); + // Assert.AreEqual(models.GetRawResponse().Content.ToString(), "[DONE]"); + // Assert.IsTrue(empty); + //} + + //[Test] + //public async Task CanEnumerateModelValues() + //{ + // MockClient client = new MockClient(); + // AsyncResultCollection models = client.GetModelsStreamingAsync(_mockContent); + + // int i = 0; + // await foreach (MockJsonModel model in models) + // { + // Assert.AreEqual(model.IntValue, i); + // Assert.AreEqual(model.StringValue, i.ToString()); + + // i++; + // } + + // Assert.AreEqual(i, 3); + //} + + //[Test] + //public void ModelCollectionThrowsIfCancelled() + //{ + // MockClient client = new MockClient(); + // AsyncResultCollection models = client.GetModelsStreamingAsync(_mockContent); + + // // Set it to `cancelled: true` to validate functionality. + // CancellationToken token = new(true); + + // Assert.ThrowsAsync(async () => + // { + // await foreach (MockJsonModel model in models.WithCancellation(token)) + // { + // } + // }); + //} + + //#region Helpers + + //private readonly string _mockContent = """ + // event: event.0 + // data: { "IntValue": 0, "StringValue": "0" } + + // event: event.1 + // data: { "IntValue": 1, "StringValue": "1" } + + // event: event.2 + // data: { "IntValue": 2, "StringValue": "2" } + + // event: done + // data: [DONE] + // + // + // """; + + //#endregion } diff --git a/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs b/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs index 41c576277c0b5..0b4fc9f454127 100644 --- a/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs +++ b/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs @@ -58,120 +58,120 @@ public virtual ClientResult GetCount(int count) } } - // mock convenience method - public virtual AsyncResultCollection GetModelsStreamingAsync(string content) - { - return new MockJsonModelCollection(content, GetModelsStreamingAsync); - } - - // mock protocol method - public virtual ClientResult GetModelsStreamingAsync(string content, RequestOptions? options = default) - { - // This mocks sending a request and returns a respose containing - // the passed-in content in the content stream. - - MockPipelineResponse response = new(); - response.SetContent(content); - - StreamingProtocolMethodCalled = true; - - return ClientResult.FromResponse(response); - } - - private class MockJsonModelCollection : AsyncResultCollection - { - private readonly string _content; - private readonly Func _protocolMethod; - - public MockJsonModelCollection(string content, Func protocolMethod) - { - _content = content; - _protocolMethod = protocolMethod; - } - - public override IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) - { - async Task getResultAsync() - { - // TODO: simulate async correctly - await Task.Delay(0, cancellationToken); - return _protocolMethod(_content, /*options:*/ default); - } - - return new AsyncMockJsonModelEnumerator(getResultAsync, this, cancellationToken); - } - - private sealed class AsyncMockJsonModelEnumerator : IAsyncEnumerator - { - private readonly Func> _getResultAsync; - private readonly MockJsonModelCollection _enumerable; - private readonly CancellationToken _cancellationToken; - - private IAsyncEnumerator? _events; - private MockJsonModel? _current; - - private bool _started; - - public AsyncMockJsonModelEnumerator(Func> getResultAsync, MockJsonModelCollection enumerable, CancellationToken cancellationToken) - { - Debug.Assert(getResultAsync is not null); - Debug.Assert(enumerable is not null); - - _getResultAsync = getResultAsync!; - _enumerable = enumerable!; - _cancellationToken = cancellationToken; - } - - MockJsonModel IAsyncEnumerator.Current - => _current!; - - async ValueTask IAsyncEnumerator.MoveNextAsync() - { - if (_events is null && _started) - { - throw new ObjectDisposedException(nameof(AsyncMockJsonModelEnumerator)); - } - - _cancellationToken.ThrowIfCancellationRequested(); - - // TODO: refactor for clarity - // Lazily initialize - if (_events is null) - { - ClientResult result = await _getResultAsync().ConfigureAwait(false); - PipelineResponse response = result.GetRawResponse(); - _enumerable.SetRawResponse(response); - - if (response.ContentStream is null) - { - throw new ArgumentException("Unable to create result from response with null ContentStream", nameof(response)); - } - - AsyncResultCollection events = Create(response, "[DONE]"); - _events = events.GetAsyncEnumerator(); - _started = true; - } - - if (await _events.MoveNextAsync().ConfigureAwait(false)) - { - MockJsonModel? model = ModelReaderWriter.Read(_events.Current); - - // TODO: should we stop iterating if we can't deserialize? - if (model is null) - { - _current = default; - return false; - } - - _current = model; - return true; - } - - _current = default; - return false; - } - - ValueTask IAsyncDisposable.DisposeAsync() => new(); - } - } + //// mock convenience method + //public virtual AsyncResultCollection GetModelsStreamingAsync(string content) + //{ + // return new MockJsonModelCollection(content, GetModelsStreamingAsync); + //} + + //// mock protocol method + //public virtual ClientResult GetModelsStreamingAsync(string content, RequestOptions? options = default) + //{ + // // This mocks sending a request and returns a respose containing + // // the passed-in content in the content stream. + + // MockPipelineResponse response = new(); + // response.SetContent(content); + + // StreamingProtocolMethodCalled = true; + + // return ClientResult.FromResponse(response); + //} + + //private class MockJsonModelCollection : AsyncResultCollection + //{ + // private readonly string _content; + // private readonly Func _protocolMethod; + + // public MockJsonModelCollection(string content, Func protocolMethod) + // { + // _content = content; + // _protocolMethod = protocolMethod; + // } + + // public override IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + // { + // async Task getResultAsync() + // { + // // TODO: simulate async correctly + // await Task.Delay(0, cancellationToken); + // return _protocolMethod(_content, /*options:*/ default); + // } + + // return new AsyncMockJsonModelEnumerator(getResultAsync, this, cancellationToken); + // } + + // private sealed class AsyncMockJsonModelEnumerator : IAsyncEnumerator + // { + // private readonly Func> _getResultAsync; + // private readonly MockJsonModelCollection _enumerable; + // private readonly CancellationToken _cancellationToken; + + // private IAsyncEnumerator? _events; + // private MockJsonModel? _current; + + // private bool _started; + + // public AsyncMockJsonModelEnumerator(Func> getResultAsync, MockJsonModelCollection enumerable, CancellationToken cancellationToken) + // { + // Debug.Assert(getResultAsync is not null); + // Debug.Assert(enumerable is not null); + + // _getResultAsync = getResultAsync!; + // _enumerable = enumerable!; + // _cancellationToken = cancellationToken; + // } + + // MockJsonModel IAsyncEnumerator.Current + // => _current!; + + // async ValueTask IAsyncEnumerator.MoveNextAsync() + // { + // if (_events is null && _started) + // { + // throw new ObjectDisposedException(nameof(AsyncMockJsonModelEnumerator)); + // } + + // _cancellationToken.ThrowIfCancellationRequested(); + + // // TODO: refactor for clarity + // // Lazily initialize + // if (_events is null) + // { + // ClientResult result = await _getResultAsync().ConfigureAwait(false); + // PipelineResponse response = result.GetRawResponse(); + // _enumerable.SetRawResponse(response); + + // if (response.ContentStream is null) + // { + // throw new ArgumentException("Unable to create result from response with null ContentStream", nameof(response)); + // } + + // AsyncResultCollection events = new AsyncSseDataEventCollection(response, terminalEvent); + // _events = events.GetAsyncEnumerator(); + // _started = true; + // } + + // if (await _events.MoveNextAsync().ConfigureAwait(false)) + // { + // MockJsonModel? model = ModelReaderWriter.Read(_events.Current); + + // // TODO: should we stop iterating if we can't deserialize? + // if (model is null) + // { + // _current = default; + // return false; + // } + + // _current = model; + // return true; + // } + + // _current = default; + // return false; + // } + + // ValueTask IAsyncDisposable.DisposeAsync() => new(); + // } + //} } diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ClientResultCollectionTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ClientResultCollectionTests.cs new file mode 100644 index 0000000000000..1ac659b56dcea --- /dev/null +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ClientResultCollectionTests.cs @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ClientModel.Primitives; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core.TestFramework; +using ClientModel.Tests.Internal.Mocks; +using ClientModel.Tests.Mocks; +using NUnit.Framework; +using SyncAsyncTestBase = ClientModel.Tests.SyncAsyncTestBase; + +namespace System.ClientModel.Tests.Convenience; + +public class ClientResultCollectionTests : SyncAsyncTestBase +{ + public ClientResultCollectionTests(bool isAsync) : base(isAsync) + { + } + + [Test] + public async Task CanEnumerateBinaryDataValues() + { + MockSseClient client = new(); + ClientResult result = client.GetModelsStreamingAsync(_mockContent, new RequestOptions()); + + int i = 0; + await foreach (BinaryData data in result.GetRawResponse().EnumerateDataEvents()) + { + MockJsonModel model = data.ToObjectFromJson(); + + Assert.AreEqual(model.IntValue, i); + Assert.AreEqual(model.StringValue, i.ToString()); + + i++; + } + + Assert.AreEqual(i, 3); + } + + [Test] + public void BinaryDataCollectionThrowsIfCancelled() + { + MockSseClient client = new(); + ClientResult result = client.GetModelsStreamingAsync(_mockContent, new RequestOptions()); + + // Set it to `cancelled: true` to validate functionality. + CancellationToken token = new(true); + + Assert.ThrowsAsync(async () => + { + await foreach (BinaryData data in result.GetRawResponse().EnumerateDataEvents().WithCancellation(token)) + { + } + }); + } + + [Test] + public async Task CanDelaySendingRequest() + { + MockSseClient client = new(); + AsyncResultCollection models = client.GetModelsStreamingAsync(_mockContent); + + Assert.IsFalse(client.ProtocolMethodCalled); + + int i = 0; + await foreach (MockJsonModel model in models) + { + Assert.AreEqual(model.IntValue, i); + Assert.AreEqual(model.StringValue, i.ToString()); + + i++; + } + + Assert.AreEqual(i, 3); + Assert.IsTrue(client.ProtocolMethodCalled); + } + + [Test] + public async Task CreatesAsyncResultCollection() + { + MockSseClient client = new(); + AsyncResultCollection models = client.GetModelsStreamingAsync("[DONE]"); + + bool empty = true; + await foreach (MockJsonModel model in models) + { + empty = false; + } + + Assert.IsNotNull(models); + Assert.AreEqual(models.GetRawResponse().Content.ToString(), "[DONE]"); + Assert.IsTrue(empty); + } + + [Test] + public async Task CanEnumerateModelValues() + { + MockSseClient client = new(); + AsyncResultCollection models = client.GetModelsStreamingAsync(_mockContent); + + int i = 0; + await foreach (MockJsonModel model in models) + { + Assert.AreEqual(model.IntValue, i); + Assert.AreEqual(model.StringValue, i.ToString()); + + i++; + } + + Assert.AreEqual(i, 3); + } + + [Test] + public void ModelCollectionThrowsIfCancelled() + { + MockSseClient client = new(); + AsyncResultCollection models = client.GetModelsStreamingAsync(_mockContent); + + // Set it to `cancelled: true` to validate functionality. + CancellationToken token = new(true); + + Assert.ThrowsAsync(async () => + { + await foreach (MockJsonModel model in models.WithCancellation(token)) + { + } + }); + } + + #region Helpers + + private readonly string _mockContent = """ + event: event.0 + data: { "IntValue": 0, "StringValue": "0" } + + event: event.1 + data: { "IntValue": 1, "StringValue": "1" } + + event: event.2 + data: { "IntValue": 2, "StringValue": "2" } + + event: done + data: [DONE] + + + """; + + #endregion +} diff --git a/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClient.cs b/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClient.cs new file mode 100644 index 0000000000000..d51ffca51e730 --- /dev/null +++ b/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClient.cs @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.ClientModel; +using System.ClientModel.Internal; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core.TestFramework; +using ClientModel.Tests.Mocks; + +namespace ClientModel.Tests.Internal.Mocks; + +// Note: keeping this mock client used to illustrate SSE usage patterns in +// Tests.Internal for now as it needs access to internal types. Once we are +// able to port to a solution that uses the public BCL SseParser type, this +// will no longer be needed. +public class MockSseClient +{ + public bool ProtocolMethodCalled { get; private set; } + + // mock convenience method + public virtual AsyncResultCollection GetModelsStreamingAsync(string content) + { + return new MockJsonModelCollection(content, GetModelsStreamingAsync); + } + + // mock protocol method + public virtual ClientResult GetModelsStreamingAsync(string content, RequestOptions? options = default) + { + // This mocks sending a request and returns a respose containing + // the passed-in content in the content stream. + + MockPipelineResponse response = new(); + response.SetContent(content); + + ProtocolMethodCalled = true; + + return ClientResult.FromResponse(response); + } + + private class MockJsonModelCollection : AsyncResultCollection + { + private readonly string _content; + private readonly Func _protocolMethod; + + public MockJsonModelCollection(string content, Func protocolMethod) + { + _content = content; + _protocolMethod = protocolMethod; + } + + public override IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + async Task getResultAsync() + { + // TODO: simulate async correctly + await Task.Delay(0, cancellationToken); + return _protocolMethod(_content, /*options:*/ default); + } + + return new AsyncMockJsonModelEnumerator(getResultAsync, this, cancellationToken); + } + + private sealed class AsyncMockJsonModelEnumerator : IAsyncEnumerator + { + private readonly Func> _getResultAsync; + private readonly MockJsonModelCollection _enumerable; + private readonly CancellationToken _cancellationToken; + + private IAsyncEnumerator? _events; + private MockJsonModel? _current; + + private bool _started; + + public AsyncMockJsonModelEnumerator(Func> getResultAsync, MockJsonModelCollection enumerable, CancellationToken cancellationToken) + { + Debug.Assert(getResultAsync is not null); + Debug.Assert(enumerable is not null); + + _getResultAsync = getResultAsync!; + _enumerable = enumerable!; + _cancellationToken = cancellationToken; + } + + MockJsonModel IAsyncEnumerator.Current + => _current!; + + async ValueTask IAsyncEnumerator.MoveNextAsync() + { + if (_events is null && _started) + { + throw new ObjectDisposedException(nameof(AsyncMockJsonModelEnumerator)); + } + + _cancellationToken.ThrowIfCancellationRequested(); + + // TODO: refactor for clarity + // Lazily initialize + if (_events is null) + { + ClientResult result = await _getResultAsync().ConfigureAwait(false); + PipelineResponse response = result.GetRawResponse(); + _enumerable.SetRawResponse(response); + + if (response.ContentStream is null) + { + throw new ArgumentException("Unable to create result from response with null ContentStream", nameof(response)); + } + + AsyncResultCollection events = new AsyncSseDataEventCollection(response, "[DONE]"); + _events = events.GetAsyncEnumerator(); + _started = true; + } + + if (await _events.MoveNextAsync().ConfigureAwait(false)) + { + MockJsonModel? model = ModelReaderWriter.Read(_events.Current); + + // TODO: should we stop iterating if we can't deserialize? + if (model is null) + { + _current = default; + return false; + } + + _current = model; + return true; + } + + _current = default; + return false; + } + + ValueTask IAsyncDisposable.DisposeAsync() => new(); + } + } +} diff --git a/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClientExtensions.cs b/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClientExtensions.cs new file mode 100644 index 0000000000000..7d37a58c9b2ac --- /dev/null +++ b/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClientExtensions.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.ClientModel; +using System.ClientModel.Internal; +using System.ClientModel.Primitives; + +namespace ClientModel.Tests.Internal.Mocks; + +public static class MockSseClientExtensions +{ + public static AsyncResultCollection EnumerateDataEvents(this PipelineResponse response) + { + if (response.ContentStream is null) + { + throw new ArgumentException("Unable to create result collection from PipelineResponse with null ContentStream", nameof(response)); + } + + return new AsyncSseDataEventCollection(response, "[DONE]"); + } +} From b8f70f7a91b753f378d9cde4c5062ed89d11102f Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Thu, 9 May 2024 15:36:47 -0700 Subject: [PATCH 36/45] update API; reimplement mock client implementations without internal BinaryData enumerable --- .../api/System.ClientModel.net6.0.cs | 1 - .../api/System.ClientModel.netstandard2.0.cs | 1 - .../SSE/AsyncServerSentEventEnumerable.cs | 61 +++++++++ .../SSE/AsyncServerSentEventEnumerator.cs | 67 ---------- .../SSE/AsyncSseDataEventCollection.cs | 85 ------------ .../ClientResultCollectionTests.cs | 111 ---------------- .../tests/TestFramework/Mocks/MockClient.cs | 123 ------------------ .../AsyncServerSentEventEnumerableTests.cs | 70 ++++++++++ .../AsyncServerSentEventEnumeratorTests.cs | 98 -------------- .../SSE/AsyncSseDataEventCollectionTests.cs | 78 ----------- .../SSE/ClientResultCollectionTests.cs | 3 +- .../TestFramework/Mocks/MockSseClient.cs | 32 +++-- .../Mocks/MockSseClientExtensions.cs | 86 ++++++++++++ 13 files changed, 238 insertions(+), 578 deletions(-) create mode 100644 sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerable.cs delete mode 100644 sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs delete mode 100644 sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseDataEventCollection.cs delete mode 100644 sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs create mode 100644 sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumerableTests.cs delete mode 100644 sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumeratorTests.cs delete mode 100644 sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncSseDataEventCollectionTests.cs diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs index e2860e78a4814..639186af98b89 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs @@ -11,7 +11,6 @@ public abstract partial class AsyncResultCollection : System.ClientModel.Clie { protected internal AsyncResultCollection() : base (default(System.ClientModel.Primitives.PipelineResponse)) { } protected internal AsyncResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } - public static System.ClientModel.AsyncResultCollection Create(System.ClientModel.Primitives.PipelineResponse response, string terminalEvent) { throw null; } public abstract System.Collections.Generic.IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); } public abstract partial class BinaryContent : System.IDisposable diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs index 1696307e37d8f..c5686b011d802 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs @@ -11,7 +11,6 @@ public abstract partial class AsyncResultCollection : System.ClientModel.Clie { protected internal AsyncResultCollection() : base (default(System.ClientModel.Primitives.PipelineResponse)) { } protected internal AsyncResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } - public static System.ClientModel.AsyncResultCollection Create(System.ClientModel.Primitives.PipelineResponse response, string terminalEvent) { throw null; } public abstract System.Collections.Generic.IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); } public abstract partial class BinaryContent : System.IDisposable diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerable.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerable.cs new file mode 100644 index 0000000000000..c6940cb6e30fa --- /dev/null +++ b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerable.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace System.ClientModel.Internal; + +internal class AsyncServerSentEventEnumerable : IAsyncEnumerable +{ + // Note: in this factoring, the creator of the enumerable has responsibility + // for disposing the content stream. + private readonly Stream _contentStream; + + public AsyncServerSentEventEnumerable(Stream contentStream) + { + _contentStream = contentStream; + } + + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + return new AsyncServerSentEventEnumerator(_contentStream, cancellationToken); + } + + private sealed class AsyncServerSentEventEnumerator : IAsyncEnumerator + { + private readonly CancellationToken _cancellationToken; + private readonly ServerSentEventReader _reader; + + public ServerSentEvent Current { get; private set; } + + public AsyncServerSentEventEnumerator(Stream contentStream, CancellationToken cancellationToken = default) + { + _reader = new(contentStream); + _cancellationToken = cancellationToken; + } + + public async ValueTask MoveNextAsync() + { + if (_reader is null) + { + throw new ObjectDisposedException(nameof(AsyncServerSentEventEnumerator)); + } + + ServerSentEvent? nextEvent = await _reader.TryGetNextEventAsync(_cancellationToken).ConfigureAwait(false); + + if (nextEvent.HasValue) + { + Current = nextEvent.Value; + return true; + } + + Current = default; + return false; + } + + public ValueTask DisposeAsync() => new ValueTask(); + } +} diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs deleted file mode 100644 index d01fb5b7f25c3..0000000000000 --- a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerator.cs +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System.Collections.Generic; -using System.IO; -using System.Threading; -using System.Threading.Tasks; - -namespace System.ClientModel.Internal; - -internal sealed class AsyncServerSentEventEnumerator : IAsyncEnumerator -{ - private readonly ReadOnlyMemory _terminalEvent; - private readonly CancellationToken _cancellationToken; - - private ServerSentEventReader? _reader; - private ServerSentEvent _current; - - public ServerSentEvent Current => _current; - - public AsyncServerSentEventEnumerator(Stream contentStream, string terminalEvent, CancellationToken cancellationToken = default) - { - _reader = new(contentStream); - _cancellationToken = cancellationToken; - _terminalEvent = terminalEvent.AsMemory(); - } - - public async ValueTask MoveNextAsync() - { - if (_reader is null) - { - throw new ObjectDisposedException(nameof(AsyncServerSentEventEnumerator)); - } - - ServerSentEvent? nextEvent = await _reader.TryGetNextEventAsync(_cancellationToken).ConfigureAwait(false); - - if (nextEvent.HasValue) - { - if (nextEvent.Value.Data.AsSpan().SequenceEqual(_terminalEvent.Span)) - { - _current = default; - return false; - } - - _current = nextEvent.Value; - return true; - } - - return false; - } - - public async ValueTask DisposeAsync() - { - await DisposeAsyncCore().ConfigureAwait(false); - - GC.SuppressFinalize(this); - } - - private async ValueTask DisposeAsyncCore() - { - if (_reader is not null) - { - await _reader.DisposeAsync().ConfigureAwait(false); - _reader = null; - } - } -} diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseDataEventCollection.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseDataEventCollection.cs deleted file mode 100644 index 2b336c6acbc33..0000000000000 --- a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncSseDataEventCollection.cs +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System.ClientModel.Primitives; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace System.ClientModel.Internal; - -internal class AsyncSseDataEventCollection : AsyncResultCollection -{ - private readonly string _terminalEvent; - - public AsyncSseDataEventCollection(PipelineResponse response, string terminalEvent) : base(response) - { - Argument.AssertNotNull(response, nameof(response)); - - _terminalEvent = terminalEvent; - } - - public override IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) - { - PipelineResponse response = GetRawResponse(); - - // We validate that response.ContentStream is non-null in - // AsyncResultCollection.Create factory method. - Debug.Assert(response.ContentStream is not null); - - return new AsyncSseDataEventEnumerator(response.ContentStream!, _terminalEvent, cancellationToken); - } - - private sealed class AsyncSseDataEventEnumerator : IAsyncEnumerator - { - private AsyncServerSentEventEnumerator? _events; - private BinaryData? _current; - - public BinaryData Current { get => _current!; } - - public AsyncSseDataEventEnumerator(Stream contentStream, string terminalEvent, CancellationToken cancellationToken) - { - Debug.Assert(contentStream is not null); - - _events = new(contentStream!, terminalEvent, cancellationToken); - } - - public async ValueTask MoveNextAsync() - { - if (_events is null) - { - throw new ObjectDisposedException(nameof(AsyncSseDataEventEnumerator)); - } - - if (await _events.MoveNextAsync().ConfigureAwait(false)) - { - char[] chars = _events.Current.Data.ToCharArray(); - byte[] bytes = Encoding.UTF8.GetBytes(chars); - _current = new BinaryData(bytes); - return true; - } - - _current = null; - return false; - } - - public async ValueTask DisposeAsync() - { - await DisposeAsyncCore().ConfigureAwait(false); - - GC.SuppressFinalize(this); - } - - private async ValueTask DisposeAsyncCore() - { - if (_events is not null) - { - await _events.DisposeAsync().ConfigureAwait(false); - _events = null; - } - } - } -} diff --git a/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs b/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs deleted file mode 100644 index 6ae1fd4df43be..0000000000000 --- a/sdk/core/System.ClientModel/tests/Convenience/ClientResultCollectionTests.cs +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System.Threading; -using System.Threading.Tasks; -using Azure.Core.TestFramework; -using ClientModel.Tests.Mocks; -using NUnit.Framework; -using SyncAsyncTestBase = ClientModel.Tests.SyncAsyncTestBase; - -namespace System.ClientModel.Tests.Results; - -public class ClientResultCollectionTests : SyncAsyncTestBase -{ - public ClientResultCollectionTests(bool isAsync) : base(isAsync) - { - } - - //[Test] - //public async Task CanDelaySendingRequest() - //{ - // MockClient client = new MockClient(); - // AsyncResultCollection models = client.GetModelsStreamingAsync(_mockContent); - - // Assert.IsFalse(client.StreamingProtocolMethodCalled); - - // int i = 0; - // await foreach (MockJsonModel model in models) - // { - // Assert.AreEqual(model.IntValue, i); - // Assert.AreEqual(model.StringValue, i.ToString()); - - // i++; - // } - - // Assert.AreEqual(i, 3); - // Assert.IsTrue(client.StreamingProtocolMethodCalled); - //} - - //[Test] - //public async Task CreatesAsyncResultCollection() - //{ - // MockClient client = new(); - // AsyncResultCollection models = client.GetModelsStreamingAsync("[DONE]"); - - // bool empty = true; - // await foreach (MockJsonModel model in models) - // { - // empty = false; - // } - - // Assert.IsNotNull(models); - // Assert.AreEqual(models.GetRawResponse().Content.ToString(), "[DONE]"); - // Assert.IsTrue(empty); - //} - - //[Test] - //public async Task CanEnumerateModelValues() - //{ - // MockClient client = new MockClient(); - // AsyncResultCollection models = client.GetModelsStreamingAsync(_mockContent); - - // int i = 0; - // await foreach (MockJsonModel model in models) - // { - // Assert.AreEqual(model.IntValue, i); - // Assert.AreEqual(model.StringValue, i.ToString()); - - // i++; - // } - - // Assert.AreEqual(i, 3); - //} - - //[Test] - //public void ModelCollectionThrowsIfCancelled() - //{ - // MockClient client = new MockClient(); - // AsyncResultCollection models = client.GetModelsStreamingAsync(_mockContent); - - // // Set it to `cancelled: true` to validate functionality. - // CancellationToken token = new(true); - - // Assert.ThrowsAsync(async () => - // { - // await foreach (MockJsonModel model in models.WithCancellation(token)) - // { - // } - // }); - //} - - //#region Helpers - - //private readonly string _mockContent = """ - // event: event.0 - // data: { "IntValue": 0, "StringValue": "0" } - - // event: event.1 - // data: { "IntValue": 1, "StringValue": "1" } - - // event: event.2 - // data: { "IntValue": 2, "StringValue": "2" } - - // event: done - // data: [DONE] - // - // - // """; - - //#endregion -} diff --git a/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs b/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs index 0b4fc9f454127..6dba7dd733db3 100644 --- a/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs +++ b/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs @@ -1,13 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using System; using System.ClientModel; -using System.ClientModel.Primitives; -using System.Collections.Generic; -using System.Diagnostics; -using System.Threading; -using System.Threading.Tasks; using Azure.Core.TestFramework; namespace ClientModel.Tests.Mocks; @@ -57,121 +51,4 @@ public virtual ClientResult GetCount(int count) return ClientResult.FromOptionalValue(default, response); } } - - //// mock convenience method - //public virtual AsyncResultCollection GetModelsStreamingAsync(string content) - //{ - // return new MockJsonModelCollection(content, GetModelsStreamingAsync); - //} - - //// mock protocol method - //public virtual ClientResult GetModelsStreamingAsync(string content, RequestOptions? options = default) - //{ - // // This mocks sending a request and returns a respose containing - // // the passed-in content in the content stream. - - // MockPipelineResponse response = new(); - // response.SetContent(content); - - // StreamingProtocolMethodCalled = true; - - // return ClientResult.FromResponse(response); - //} - - //private class MockJsonModelCollection : AsyncResultCollection - //{ - // private readonly string _content; - // private readonly Func _protocolMethod; - - // public MockJsonModelCollection(string content, Func protocolMethod) - // { - // _content = content; - // _protocolMethod = protocolMethod; - // } - - // public override IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) - // { - // async Task getResultAsync() - // { - // // TODO: simulate async correctly - // await Task.Delay(0, cancellationToken); - // return _protocolMethod(_content, /*options:*/ default); - // } - - // return new AsyncMockJsonModelEnumerator(getResultAsync, this, cancellationToken); - // } - - // private sealed class AsyncMockJsonModelEnumerator : IAsyncEnumerator - // { - // private readonly Func> _getResultAsync; - // private readonly MockJsonModelCollection _enumerable; - // private readonly CancellationToken _cancellationToken; - - // private IAsyncEnumerator? _events; - // private MockJsonModel? _current; - - // private bool _started; - - // public AsyncMockJsonModelEnumerator(Func> getResultAsync, MockJsonModelCollection enumerable, CancellationToken cancellationToken) - // { - // Debug.Assert(getResultAsync is not null); - // Debug.Assert(enumerable is not null); - - // _getResultAsync = getResultAsync!; - // _enumerable = enumerable!; - // _cancellationToken = cancellationToken; - // } - - // MockJsonModel IAsyncEnumerator.Current - // => _current!; - - // async ValueTask IAsyncEnumerator.MoveNextAsync() - // { - // if (_events is null && _started) - // { - // throw new ObjectDisposedException(nameof(AsyncMockJsonModelEnumerator)); - // } - - // _cancellationToken.ThrowIfCancellationRequested(); - - // // TODO: refactor for clarity - // // Lazily initialize - // if (_events is null) - // { - // ClientResult result = await _getResultAsync().ConfigureAwait(false); - // PipelineResponse response = result.GetRawResponse(); - // _enumerable.SetRawResponse(response); - - // if (response.ContentStream is null) - // { - // throw new ArgumentException("Unable to create result from response with null ContentStream", nameof(response)); - // } - - // AsyncResultCollection events = new AsyncSseDataEventCollection(response, terminalEvent); - // _events = events.GetAsyncEnumerator(); - // _started = true; - // } - - // if (await _events.MoveNextAsync().ConfigureAwait(false)) - // { - // MockJsonModel? model = ModelReaderWriter.Read(_events.Current); - - // // TODO: should we stop iterating if we can't deserialize? - // if (model is null) - // { - // _current = default; - // return false; - // } - - // _current = model; - // return true; - // } - - // _current = default; - // return false; - // } - - // ValueTask IAsyncDisposable.DisposeAsync() => new(); - // } - //} } diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumerableTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumerableTests.cs new file mode 100644 index 0000000000000..94601ede4987e --- /dev/null +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumerableTests.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ClientModel.Internal; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; + +namespace System.ClientModel.Tests.Convenience; + +public class AsyncServerSentEventEnumerableTests +{ + [Test] + public async Task EnumeratesEvents() + { + using Stream contentStream = BinaryData.FromString(_mockContent).ToStream(); + AsyncServerSentEventEnumerable enumerable = new(contentStream); + + List events = new(); + + await foreach (ServerSentEvent sse in enumerable) + { + events.Add(sse); + } + + Assert.AreEqual(4, events.Count); + + for (int i = 0; i < 3; i++) + { + Assert.AreEqual($"event.{i}", events[i].EventType); + Assert.AreEqual($"{{ \"id\": \"{i}\", \"object\": {i} }}", events[i].Data); + } + } + + [Test] + public void ThrowsIfCancelled() + { + CancellationToken token = new(true); + + using Stream contentStream = BinaryData.FromString(_mockContent).ToStream(); + AsyncServerSentEventEnumerable enumerable = new(contentStream); + IAsyncEnumerator enumerator = enumerable.GetAsyncEnumerator(token); + + Assert.ThrowsAsync(async () => await enumerator.MoveNextAsync()); + } + + // TODO: Add tests for dispose + + #region Helpers + + private string _mockContent = """ + event: event.0 + data: { "id": "0", "object": 0 } + + event: event.1 + data: { "id": "1", "object": 1 } + + event: event.2 + data: { "id": "2", "object": 2 } + + event: done + data: [DONE] + + + """; + + #endregion +} diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumeratorTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumeratorTests.cs deleted file mode 100644 index c09341259c08e..0000000000000 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumeratorTests.cs +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System.ClientModel.Internal; -using System.Collections.Generic; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using NUnit.Framework; - -namespace System.ClientModel.Tests.Convenience; - -public class AsyncServerSentEventEnumeratorTests -{ - [Test] - public async Task EnumeratesEvents() - { - using Stream contentStream = BinaryData.FromString(_mockContent).ToStream(); - AsyncServerSentEventEnumerator enumerator = new(contentStream, "[DONE]"); - - int i = 0; - while (await enumerator.MoveNextAsync()) - { - ServerSentEvent sse = enumerator.Current; - - Assert.IsTrue(sse.EventType.AsSpan().SequenceEqual($"event.{i}".AsSpan())); - Assert.IsTrue(sse.Data.AsSpan().SequenceEqual($"{{ \"id\": \"{i}\", \"object\": {i} }}".AsSpan())); - - i++; - } - - Assert.AreEqual(i, 3); - } - - [Test] - public void ThrowsIfCancelled() - { - CancellationToken token = new(true); - - using Stream contentStream = BinaryData.FromString(_mockContent).ToStream(); - AsyncServerSentEventEnumerator enumerator = new(contentStream, "[DONE]", token); - - Assert.ThrowsAsync(async () => await enumerator.MoveNextAsync()); - } - - [Test] - public async Task StopsOnStringBasedTerminalEvent() - { - string mockContent = """ - event: event.0 - data: 0 - - event: stop - data: ~stop~ - - event: event.1 - data: 1 - - - """; - - using Stream contentStream = BinaryData.FromString(mockContent).ToStream(); - AsyncServerSentEventEnumerator enumerator = new(contentStream, "~stop~"); - - List events = new(); - - while (await enumerator.MoveNextAsync()) - { - events.Add(enumerator.Current); - } - - Assert.AreEqual(events.Count, 1); - Assert.IsTrue(events[0].EventType.AsSpan().SequenceEqual("event.0".AsSpan())); - Assert.IsTrue(events[0].Data.AsSpan().SequenceEqual("0".AsSpan())); - } - - // TODO: Add tests for dispose - - #region Helpers - - private string _mockContent = """ - event: event.0 - data: { "id": "0", "object": 0 } - - event: event.1 - data: { "id": "1", "object": 1 } - - event: event.2 - data: { "id": "2", "object": 2 } - - event: done - data: [DONE] - - - """; - - #endregion -} diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncSseDataEventCollectionTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncSseDataEventCollectionTests.cs deleted file mode 100644 index 3db6139899b00..0000000000000 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncSseDataEventCollectionTests.cs +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System.ClientModel.Internal; -using System.Threading; -using System.Threading.Tasks; -using Azure.Core.TestFramework; -using ClientModel.Tests.Mocks; -using NUnit.Framework; - -namespace System.ClientModel.Tests.Convenience; - -public class AsyncSseDataEventCollectionTests -{ - [Test] - public async Task BinaryDataCollectionEnumeratesData() - { - MockPipelineResponse response = new(); - response.SetContent(_mockContent); - - AsyncSseDataEventCollection results = new(response, "[DONE]"); - - int i = 0; - await foreach (BinaryData result in results) - { - MockJsonModel model = result.ToObjectFromJson(); - - Assert.AreEqual(model.IntValue, i); - Assert.AreEqual(model.StringValue, i.ToString()); - - i++; - } - - Assert.AreEqual(i, 3); - } - - [Test] - public void BinaryDataCollectionThrowsIfCancelled() - { - MockPipelineResponse response = new(); - response.SetContent(_mockContent); - - AsyncSseDataEventCollection results = new(response, "[DONE]"); - - CancellationToken token = new(true); - - Assert.ThrowsAsync(async () => - { - await foreach (BinaryData result in results.WithCancellation(token)) - { - } - }); - } - - // TODO: Add tests for dispose and handling cancellation token - // TODO: later, add tests for varying the _doneToken value. - // TODO: tests for infinite stream -- no terminal event; how to show it won't stop? - - #region Helpers - - private readonly string _mockContent = """ - event: event.0 - data: { "IntValue": 0, "StringValue": "0" } - - event: event.1 - data: { "IntValue": 1, "StringValue": "1" } - - event: event.2 - data: { "IntValue": 2, "StringValue": "2" } - - event: done - data: [DONE] - - - """; - - #endregion -} diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ClientResultCollectionTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ClientResultCollectionTests.cs index 1ac659b56dcea..fbe4078d465bb 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ClientResultCollectionTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ClientResultCollectionTests.cs @@ -6,7 +6,6 @@ using System.Threading.Tasks; using Azure.Core.TestFramework; using ClientModel.Tests.Internal.Mocks; -using ClientModel.Tests.Mocks; using NUnit.Framework; using SyncAsyncTestBase = ClientModel.Tests.SyncAsyncTestBase; @@ -77,7 +76,7 @@ public async Task CanDelaySendingRequest() } [Test] - public async Task CreatesAsyncResultCollection() + public async Task StopsOnStringBasedTerminalEvent() { MockSseClient client = new(); AsyncResultCollection models = client.GetModelsStreamingAsync("[DONE]"); diff --git a/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClient.cs b/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClient.cs index d51ffca51e730..4f7ef08545a61 100644 --- a/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClient.cs +++ b/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClient.cs @@ -7,6 +7,7 @@ using System.ClientModel.Primitives; using System.Collections.Generic; using System.Diagnostics; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Azure.Core.TestFramework; @@ -25,7 +26,7 @@ public class MockSseClient // mock convenience method public virtual AsyncResultCollection GetModelsStreamingAsync(string content) { - return new MockJsonModelCollection(content, GetModelsStreamingAsync); + return new AsyncMockJsonModelCollection(content, GetModelsStreamingAsync); } // mock protocol method @@ -42,12 +43,15 @@ public virtual ClientResult GetModelsStreamingAsync(string content, RequestOptio return ClientResult.FromResponse(response); } - private class MockJsonModelCollection : AsyncResultCollection + // Internal client implementation of convenience-layer AsyncResultCollection. + // This currently layers over an internal AsyncResultCollection + // representing the event.data values, but does not strictly have to. + private class AsyncMockJsonModelCollection : AsyncResultCollection { private readonly string _content; private readonly Func _protocolMethod; - public MockJsonModelCollection(string content, Func protocolMethod) + public AsyncMockJsonModelCollection(string content, Func protocolMethod) { _content = content; _protocolMethod = protocolMethod; @@ -67,16 +71,18 @@ async Task getResultAsync() private sealed class AsyncMockJsonModelEnumerator : IAsyncEnumerator { + private const string _terminalData = "[DONE]"; + private readonly Func> _getResultAsync; - private readonly MockJsonModelCollection _enumerable; + private readonly AsyncMockJsonModelCollection _enumerable; private readonly CancellationToken _cancellationToken; - private IAsyncEnumerator? _events; + private IAsyncEnumerator? _events; private MockJsonModel? _current; private bool _started; - public AsyncMockJsonModelEnumerator(Func> getResultAsync, MockJsonModelCollection enumerable, CancellationToken cancellationToken) + public AsyncMockJsonModelEnumerator(Func> getResultAsync, AsyncMockJsonModelCollection enumerable, CancellationToken cancellationToken) { Debug.Assert(getResultAsync is not null); Debug.Assert(enumerable is not null); @@ -111,22 +117,24 @@ async ValueTask IAsyncEnumerator.MoveNextAsync() throw new ArgumentException("Unable to create result from response with null ContentStream", nameof(response)); } - AsyncResultCollection events = new AsyncSseDataEventCollection(response, "[DONE]"); - _events = events.GetAsyncEnumerator(); + AsyncServerSentEventEnumerable enumerable = new(response.ContentStream); + _events = enumerable.GetAsyncEnumerator(_cancellationToken); _started = true; } if (await _events.MoveNextAsync().ConfigureAwait(false)) { - MockJsonModel? model = ModelReaderWriter.Read(_events.Current); - - // TODO: should we stop iterating if we can't deserialize? - if (model is null) + if (_events.Current.Data == _terminalData) { _current = default; return false; } + BinaryData data = BinaryData.FromString(_events.Current.Data); + + MockJsonModel? model = ModelReaderWriter.Read(data) ?? + throw new JsonException($"Failed to deserialize expected type MockJsonModel from sse data payload '{_events.Current.Data}'."); + _current = model; return true; } diff --git a/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClientExtensions.cs b/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClientExtensions.cs index 7d37a58c9b2ac..00ebadbe6e60f 100644 --- a/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClientExtensions.cs +++ b/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClientExtensions.cs @@ -5,6 +5,11 @@ using System.ClientModel; using System.ClientModel.Internal; using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; namespace ClientModel.Tests.Internal.Mocks; @@ -19,4 +24,85 @@ public static AsyncResultCollection EnumerateDataEvents(this Pipelin return new AsyncSseDataEventCollection(response, "[DONE]"); } + + private class AsyncSseDataEventCollection : AsyncResultCollection + { + private readonly string _terminalData; + + public AsyncSseDataEventCollection(PipelineResponse response, string terminalData) : base(response) + { + Argument.AssertNotNull(response, nameof(response)); + + _terminalData = terminalData; + } + + public override IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + PipelineResponse response = GetRawResponse(); + + // We validate that response.ContentStream is non-null in outer extension method. + Debug.Assert(response.ContentStream is not null); + + return new AsyncSseDataEventEnumerator(response.ContentStream!, _terminalData, cancellationToken); + } + + private sealed class AsyncSseDataEventEnumerator : IAsyncEnumerator + { + private readonly string _terminalData; + + private IAsyncEnumerator? _events; + private BinaryData? _current; + + public BinaryData Current { get => _current!; } + + public AsyncSseDataEventEnumerator(Stream contentStream, string terminalData, CancellationToken cancellationToken) + { + Debug.Assert(contentStream is not null); + + AsyncServerSentEventEnumerable enumerable = new(contentStream!); + _events = enumerable.GetAsyncEnumerator(cancellationToken); + + _terminalData = terminalData; + } + + public async ValueTask MoveNextAsync() + { + if (_events is null) + { + throw new ObjectDisposedException(nameof(AsyncSseDataEventEnumerator)); + } + + if (await _events.MoveNextAsync().ConfigureAwait(false)) + { + if (_events.Current.Data == _terminalData) + { + _current = default; + return false; + } + + _current = BinaryData.FromString(_events.Current.Data); + return true; + } + + _current = default; + return false; + } + + public async ValueTask DisposeAsync() + { + await DisposeAsyncCore().ConfigureAwait(false); + + GC.SuppressFinalize(this); + } + + private async ValueTask DisposeAsyncCore() + { + if (_events is not null) + { + await _events.DisposeAsync().ConfigureAwait(false); + _events = null; + } + } + } + } } From a592c2c1dbf24ab68aa747f6fa638aa91a782387 Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Thu, 9 May 2024 16:16:44 -0700 Subject: [PATCH 37/45] Add sync client result collection abstraction --- .../api/System.ClientModel.net6.0.cs | 13 +++- .../api/System.ClientModel.netstandard2.0.cs | 13 +++- ...T.cs => AsyncClientResultCollectionOfT.cs} | 6 +- .../src/Convenience/ClientResult.cs | 2 +- .../Convenience/ClientResultCollectionOfT.cs | 38 +++++------ .../SSE/AsyncServerSentEventEnumerable.cs | 19 +++--- .../Internal/SSE/ServerSentEventEnumerable.cs | 64 +++++++++++++++++++ .../SSE/ClientResultCollectionTests.cs | 8 +-- .../SSE/ServerSentEventEnumerableTests.cs | 54 ++++++++++++++++ .../TestFramework/Mocks/MockSseClient.cs | 4 +- .../Mocks/MockSseClientExtensions.cs | 4 +- 11 files changed, 180 insertions(+), 45 deletions(-) rename sdk/core/System.ClientModel/src/Convenience/{AsyncResultCollectionOfT.cs => AsyncClientResultCollectionOfT.cs} (78%) create mode 100644 sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventEnumerable.cs create mode 100644 sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventEnumerableTests.cs diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs index 639186af98b89..8c80026f6520e 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs @@ -7,10 +7,10 @@ public ApiKeyCredential(string key) { } public static implicit operator System.ClientModel.ApiKeyCredential (string key) { throw null; } public void Update(string key) { } } - public abstract partial class AsyncResultCollection : System.ClientModel.ClientResult, System.Collections.Generic.IAsyncEnumerable + public abstract partial class AsyncClientResultCollection : System.ClientModel.ClientResult, System.Collections.Generic.IAsyncEnumerable { - protected internal AsyncResultCollection() : base (default(System.ClientModel.Primitives.PipelineResponse)) { } - protected internal AsyncResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } + protected internal AsyncClientResultCollection() : base (default(System.ClientModel.Primitives.PipelineResponse)) { } + protected internal AsyncClientResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } public abstract System.Collections.Generic.IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); } public abstract partial class BinaryContent : System.IDisposable @@ -33,6 +33,13 @@ protected ClientResult(System.ClientModel.Primitives.PipelineResponse? response) public System.ClientModel.Primitives.PipelineResponse GetRawResponse() { throw null; } protected void SetRawResponse(System.ClientModel.Primitives.PipelineResponse response) { } } + public abstract partial class ClientResultCollection : System.ClientModel.ClientResult, System.Collections.Generic.IEnumerable, System.Collections.IEnumerable + { + protected internal ClientResultCollection() : base (default(System.ClientModel.Primitives.PipelineResponse)) { } + protected internal ClientResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } + public abstract System.Collections.Generic.IEnumerator GetEnumerator(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { throw null; } + } public partial class ClientResultException : System.Exception { public ClientResultException(System.ClientModel.Primitives.PipelineResponse response, System.Exception? innerException = null) { } diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs index c5686b011d802..71c8abf89cf29 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs @@ -7,10 +7,10 @@ public ApiKeyCredential(string key) { } public static implicit operator System.ClientModel.ApiKeyCredential (string key) { throw null; } public void Update(string key) { } } - public abstract partial class AsyncResultCollection : System.ClientModel.ClientResult, System.Collections.Generic.IAsyncEnumerable + public abstract partial class AsyncClientResultCollection : System.ClientModel.ClientResult, System.Collections.Generic.IAsyncEnumerable { - protected internal AsyncResultCollection() : base (default(System.ClientModel.Primitives.PipelineResponse)) { } - protected internal AsyncResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } + protected internal AsyncClientResultCollection() : base (default(System.ClientModel.Primitives.PipelineResponse)) { } + protected internal AsyncClientResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } public abstract System.Collections.Generic.IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); } public abstract partial class BinaryContent : System.IDisposable @@ -33,6 +33,13 @@ protected ClientResult(System.ClientModel.Primitives.PipelineResponse? response) public System.ClientModel.Primitives.PipelineResponse GetRawResponse() { throw null; } protected void SetRawResponse(System.ClientModel.Primitives.PipelineResponse response) { } } + public abstract partial class ClientResultCollection : System.ClientModel.ClientResult, System.Collections.Generic.IEnumerable, System.Collections.IEnumerable + { + protected internal ClientResultCollection() : base (default(System.ClientModel.Primitives.PipelineResponse)) { } + protected internal ClientResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } + public abstract System.Collections.Generic.IEnumerator GetEnumerator(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { throw null; } + } public partial class ClientResultException : System.Exception { public ClientResultException(System.ClientModel.Primitives.PipelineResponse response, System.Exception? innerException = null) { } diff --git a/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs b/sdk/core/System.ClientModel/src/Convenience/AsyncClientResultCollectionOfT.cs similarity index 78% rename from sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs rename to sdk/core/System.ClientModel/src/Convenience/AsyncClientResultCollectionOfT.cs index 68bbbc9d17a51..42706a7e70c3a 100644 --- a/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs +++ b/sdk/core/System.ClientModel/src/Convenience/AsyncClientResultCollectionOfT.cs @@ -8,19 +8,19 @@ namespace System.ClientModel; #pragma warning disable CS1591 // public XML comments -public abstract class AsyncResultCollection : ClientResult, IAsyncEnumerable +public abstract class AsyncClientResultCollection : ClientResult, IAsyncEnumerable { // Constructor overload for collection implementations that postpone // sending a request until GetAsyncEnumerator is called. This will typically // be used by collections returned from client convenience methods. - protected internal AsyncResultCollection() : base(default) + protected internal AsyncClientResultCollection() : base(default) { } // Constructor overload for collection implementations where the service // has returned a response. This will typically be used by collections // created from the return result of a client's protocol method. - protected internal AsyncResultCollection(PipelineResponse response) : base(response) + protected internal AsyncClientResultCollection(PipelineResponse response) : base(response) { } diff --git a/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs b/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs index 7366785117eb7..5a737eb511326 100644 --- a/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs +++ b/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs @@ -32,7 +32,7 @@ protected ClientResult(PipelineResponse? response) /// No /// value is currently available for this /// instance. This can happen when the instance - /// is a collection type like + /// is a collection type like /// that has not yet been enumerated. public PipelineResponse GetRawResponse() { diff --git a/sdk/core/System.ClientModel/src/Convenience/ClientResultCollectionOfT.cs b/sdk/core/System.ClientModel/src/Convenience/ClientResultCollectionOfT.cs index b416813377c93..4a6622080d5be 100644 --- a/sdk/core/System.ClientModel/src/Convenience/ClientResultCollectionOfT.cs +++ b/sdk/core/System.ClientModel/src/Convenience/ClientResultCollectionOfT.cs @@ -1,31 +1,31 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using System.ClientModel.Internal; using System.ClientModel.Primitives; using System.Collections; using System.Collections.Generic; namespace System.ClientModel; -// TODO: Re-enable sync version +#pragma warning disable CS1591 // public XML comments +public abstract class ClientResultCollection : ClientResult, IEnumerable +{ + // Constructor overload for collection implementations that postpone + // sending a request until GetAsyncEnumerator is called. This will typically + // be used by collections returned from client convenience methods. + protected internal ClientResultCollection() : base(default) + { + } -//#pragma warning disable CS1591 // public XML comments -//public abstract class ClientResultCollection : ClientResult, IEnumerable -//{ -// protected internal ClientResultCollection(PipelineResponse response) : base(response) -// { -// } + // Constructor overload for collection implementations where the service + // has returned a response. This will typically be used by collections + // created from the return result of a client's protocol method. + protected internal ClientResultCollection(PipelineResponse response) : base(response) + { + } -// public abstract IEnumerator GetEnumerator(); + public abstract IEnumerator GetEnumerator(); -// IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - -// // TODO: take CancellationToken? -// //public static ClientResultCollection Create(PipelineResponse response) where TValue : IJsonModel -// public static ClientResultCollection Create(PipelineResponse response) where TValue : IJsonModel -// { -// return StreamingClientResult.Create(response); -// } -//} -//#pragma warning restore CS1591 // public XML comments + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} +#pragma warning restore CS1591 // public XML comments diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerable.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerable.cs index c6940cb6e30fa..5ce063e8fcf7b 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerable.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerable.cs @@ -10,8 +10,6 @@ namespace System.ClientModel.Internal; internal class AsyncServerSentEventEnumerable : IAsyncEnumerable { - // Note: in this factoring, the creator of the enumerable has responsibility - // for disposing the content stream. private readonly Stream _contentStream; public AsyncServerSentEventEnumerable(Stream contentStream) @@ -39,11 +37,6 @@ public AsyncServerSentEventEnumerator(Stream contentStream, CancellationToken ca public async ValueTask MoveNextAsync() { - if (_reader is null) - { - throw new ObjectDisposedException(nameof(AsyncServerSentEventEnumerator)); - } - ServerSentEvent? nextEvent = await _reader.TryGetNextEventAsync(_cancellationToken).ConfigureAwait(false); if (nextEvent.HasValue) @@ -56,6 +49,16 @@ public async ValueTask MoveNextAsync() return false; } - public ValueTask DisposeAsync() => new ValueTask(); + public ValueTask DisposeAsync() + { + // The creator of the enumerable has responsibility for disposing + // the content stream passed to the enumerable constructor. + +#if NET6_0_OR_GREATER + return ValueTask.CompletedTask; +#else + return new ValueTask(); +#endif + } } } diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventEnumerable.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventEnumerable.cs new file mode 100644 index 0000000000000..ab113508846d7 --- /dev/null +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventEnumerable.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections; +using System.Collections.Generic; +using System.IO; + +namespace System.ClientModel.Internal; + +internal class ServerSentEventEnumerable : IEnumerable +{ + private readonly Stream _contentStream; + + public ServerSentEventEnumerable(Stream contentStream) + { + _contentStream = contentStream; + } + + public IEnumerator GetEnumerator() + { + return new ServerSentEventEnumerator(_contentStream); + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + private sealed class ServerSentEventEnumerator : IEnumerator + { + private readonly ServerSentEventReader _reader; + + public ServerSentEventEnumerator(Stream contentStream) + { + _reader = new(contentStream); + } + + public ServerSentEvent Current { get; private set; } + + object IEnumerator.Current => Current; + + public bool MoveNext() + { + ServerSentEvent? nextEvent = _reader.TryGetNextEvent(); + + if (nextEvent.HasValue) + { + Current = nextEvent.Value; + return true; + } + + Current = default; + return false; + } + + public void Reset() + { + throw new NotSupportedException("Cannot seek back in an SSE stream."); + } + + public void Dispose() + { + // The creator of the enumerable has responsibility for disposing + // the content stream passed to the enumerable constructor. + } + } +} diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ClientResultCollectionTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ClientResultCollectionTests.cs index fbe4078d465bb..b4103f192989d 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ClientResultCollectionTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ClientResultCollectionTests.cs @@ -58,7 +58,7 @@ public void BinaryDataCollectionThrowsIfCancelled() public async Task CanDelaySendingRequest() { MockSseClient client = new(); - AsyncResultCollection models = client.GetModelsStreamingAsync(_mockContent); + AsyncClientResultCollection models = client.GetModelsStreamingAsync(_mockContent); Assert.IsFalse(client.ProtocolMethodCalled); @@ -79,7 +79,7 @@ public async Task CanDelaySendingRequest() public async Task StopsOnStringBasedTerminalEvent() { MockSseClient client = new(); - AsyncResultCollection models = client.GetModelsStreamingAsync("[DONE]"); + AsyncClientResultCollection models = client.GetModelsStreamingAsync("[DONE]"); bool empty = true; await foreach (MockJsonModel model in models) @@ -96,7 +96,7 @@ public async Task StopsOnStringBasedTerminalEvent() public async Task CanEnumerateModelValues() { MockSseClient client = new(); - AsyncResultCollection models = client.GetModelsStreamingAsync(_mockContent); + AsyncClientResultCollection models = client.GetModelsStreamingAsync(_mockContent); int i = 0; await foreach (MockJsonModel model in models) @@ -114,7 +114,7 @@ public async Task CanEnumerateModelValues() public void ModelCollectionThrowsIfCancelled() { MockSseClient client = new(); - AsyncResultCollection models = client.GetModelsStreamingAsync(_mockContent); + AsyncClientResultCollection models = client.GetModelsStreamingAsync(_mockContent); // Set it to `cancelled: true` to validate functionality. CancellationToken token = new(true); diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventEnumerableTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventEnumerableTests.cs new file mode 100644 index 0000000000000..0ec39af761e34 --- /dev/null +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventEnumerableTests.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ClientModel.Internal; +using System.Collections.Generic; +using System.IO; +using NUnit.Framework; + +namespace System.ClientModel.Tests.Convenience; + +public class ServerSentEventEnumerableTests +{ + [Test] + public void EnumeratesEvents() + { + using Stream contentStream = BinaryData.FromString(_mockContent).ToStream(); + ServerSentEventEnumerable enumerable = new(contentStream); + + List events = new(); + + foreach (ServerSentEvent sse in enumerable) + { + events.Add(sse); + } + + Assert.AreEqual(4, events.Count); + + for (int i = 0; i < 3; i++) + { + Assert.AreEqual($"event.{i}", events[i].EventType); + Assert.AreEqual($"{{ \"id\": \"{i}\", \"object\": {i} }}", events[i].Data); + } + } + + #region Helpers + + private readonly string _mockContent = """ + event: event.0 + data: { "id": "0", "object": 0 } + + event: event.1 + data: { "id": "1", "object": 1 } + + event: event.2 + data: { "id": "2", "object": 2 } + + event: done + data: [DONE] + + + """; + + #endregion +} diff --git a/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClient.cs b/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClient.cs index 4f7ef08545a61..7495017b2ce8f 100644 --- a/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClient.cs +++ b/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClient.cs @@ -24,7 +24,7 @@ public class MockSseClient public bool ProtocolMethodCalled { get; private set; } // mock convenience method - public virtual AsyncResultCollection GetModelsStreamingAsync(string content) + public virtual AsyncClientResultCollection GetModelsStreamingAsync(string content) { return new AsyncMockJsonModelCollection(content, GetModelsStreamingAsync); } @@ -46,7 +46,7 @@ public virtual ClientResult GetModelsStreamingAsync(string content, RequestOptio // Internal client implementation of convenience-layer AsyncResultCollection. // This currently layers over an internal AsyncResultCollection // representing the event.data values, but does not strictly have to. - private class AsyncMockJsonModelCollection : AsyncResultCollection + private class AsyncMockJsonModelCollection : AsyncClientResultCollection { private readonly string _content; private readonly Func _protocolMethod; diff --git a/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClientExtensions.cs b/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClientExtensions.cs index 00ebadbe6e60f..2fcad366c2662 100644 --- a/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClientExtensions.cs +++ b/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClientExtensions.cs @@ -15,7 +15,7 @@ namespace ClientModel.Tests.Internal.Mocks; public static class MockSseClientExtensions { - public static AsyncResultCollection EnumerateDataEvents(this PipelineResponse response) + public static AsyncClientResultCollection EnumerateDataEvents(this PipelineResponse response) { if (response.ContentStream is null) { @@ -25,7 +25,7 @@ public static AsyncResultCollection EnumerateDataEvents(this Pipelin return new AsyncSseDataEventCollection(response, "[DONE]"); } - private class AsyncSseDataEventCollection : AsyncResultCollection + private class AsyncSseDataEventCollection : AsyncClientResultCollection { private readonly string _terminalData; From 88de1216fd88b4597e37e5b316b0d5133083f492 Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Thu, 9 May 2024 17:27:34 -0700 Subject: [PATCH 38/45] tidy up and add tests --- .../src/Internal/SSE/ServerSentEvent.cs | 2 +- .../src/Internal/SSE/ServerSentEventField.cs | 2 +- .../Internal/SSE/ServerSentEventFieldKind.cs | 2 +- .../src/Internal/SSE/ServerSentEventReader.cs | 8 +- .../AsyncServerSentEventEnumerableTests.cs | 4 +- .../SSE/ClientResultCollectionTests.cs | 91 +++++++++++++------ .../SSE/ServerSentEventFieldTests.cs | 4 +- .../SSE/ServerSentEventReaderTests.cs | 82 ++++++++++------- .../Convenience/SSE/ServerSentEventTests.cs | 28 ++---- .../TestFramework/Mocks/MockSseClient.cs | 67 +++++++++----- .../Mocks/MockSyncAsyncInternalExtensions.cs | 22 +++++ 11 files changed, 203 insertions(+), 109 deletions(-) create mode 100644 sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSyncAsyncInternalExtensions.cs diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs index 4e950afabfdb6..04b46f65e3bf7 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs @@ -3,7 +3,7 @@ namespace System.ClientModel.Internal; -// SSE specification: https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream +// SSE specification: https://html.spec.whatwg.org/multipage/server-sent-events.html internal readonly struct ServerSentEvent { // Gets the value of the SSE "event type" buffer, used to distinguish between event kinds. diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventField.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventField.cs index de61cf5a4e3cf..ff763a527a6b7 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventField.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventField.cs @@ -3,7 +3,7 @@ namespace System.ClientModel.Internal; -// SSE specification: https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream +// SSE specification: https://html.spec.whatwg.org/multipage/server-sent-events.html internal readonly struct ServerSentEventField { private static readonly ReadOnlyMemory s_eventFieldName = "event".AsMemory(); diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventFieldKind.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventFieldKind.cs index 34a1cd22c67a2..d0208bc21ead7 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventFieldKind.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventFieldKind.cs @@ -3,7 +3,7 @@ namespace System.ClientModel.Internal; -// SSE specification: https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream +// SSE specification: https://html.spec.whatwg.org/multipage/server-sent-events.html internal enum ServerSentEventFieldKind { Ignore, diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs index 5fff35faff83e..5181487dc8019 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs @@ -9,7 +9,7 @@ namespace System.ClientModel.Internal; -// TODO: Different sync and async readers to dispose differently? +// SSE specification: https://html.spec.whatwg.org/multipage/server-sent-events.html internal sealed class ServerSentEventReader : IDisposable, IAsyncDisposable { private Stream? _stream; @@ -43,7 +43,8 @@ public ServerSentEventReader(Stream stream) { cancellationToken.ThrowIfCancellationRequested(); - // TODO: Pass cancellationToken? + // Note: would be nice to have polyfill that takes cancellation token, + // but may become moot if we shift to all UTF-8. string? line = _reader.ReadLine(); if (line is null) @@ -81,7 +82,8 @@ public ServerSentEventReader(Stream stream) { cancellationToken.ThrowIfCancellationRequested(); - // TODO: Pass cancellationToken? + // Note: would be nice to have polyfill that takes cancellation token, + // but may become moot if we shift to all UTF-8. string? line = await _reader.ReadLineAsync().ConfigureAwait(false); if (line is null) diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumerableTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumerableTests.cs index 94601ede4987e..385e2fc58c846 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumerableTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumerableTests.cs @@ -46,11 +46,9 @@ public void ThrowsIfCancelled() Assert.ThrowsAsync(async () => await enumerator.MoveNextAsync()); } - // TODO: Add tests for dispose - #region Helpers - private string _mockContent = """ + private readonly string _mockContent = """ event: event.0 data: { "id": "0", "object": 0 } diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ClientResultCollectionTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ClientResultCollectionTests.cs index b4103f192989d..70808b56e4a95 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ClientResultCollectionTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ClientResultCollectionTests.cs @@ -18,7 +18,7 @@ public ClientResultCollectionTests(bool isAsync) : base(isAsync) } [Test] - public async Task CanEnumerateBinaryDataValues() + public async Task EnumeratesDataValues() { MockSseClient client = new(); ClientResult result = client.GetModelsStreamingAsync(_mockContent, new RequestOptions()); @@ -28,17 +28,17 @@ public async Task CanEnumerateBinaryDataValues() { MockJsonModel model = data.ToObjectFromJson(); - Assert.AreEqual(model.IntValue, i); - Assert.AreEqual(model.StringValue, i.ToString()); + Assert.AreEqual(i, model.IntValue); + Assert.AreEqual(i.ToString(), model.StringValue); i++; } - Assert.AreEqual(i, 3); + Assert.AreEqual(3, i); } [Test] - public void BinaryDataCollectionThrowsIfCancelled() + public void DataCollectionThrowsIfCancelled() { MockSseClient client = new(); ClientResult result = client.GetModelsStreamingAsync(_mockContent, new RequestOptions()); @@ -55,59 +55,55 @@ public void BinaryDataCollectionThrowsIfCancelled() } [Test] - public async Task CanDelaySendingRequest() + public async Task DataCollectionDoesNotDisposeStream() { MockSseClient client = new(); - AsyncClientResultCollection models = client.GetModelsStreamingAsync(_mockContent); - - Assert.IsFalse(client.ProtocolMethodCalled); + ClientResult result = client.GetModelsStreamingAsync(_mockContent, new RequestOptions()); - int i = 0; - await foreach (MockJsonModel model in models) + await foreach (BinaryData data in result.GetRawResponse().EnumerateDataEvents()) { - Assert.AreEqual(model.IntValue, i); - Assert.AreEqual(model.StringValue, i.ToString()); - - i++; } - Assert.AreEqual(i, 3); - Assert.IsTrue(client.ProtocolMethodCalled); + Assert.DoesNotThrow(() => { var p = result.GetRawResponse().ContentStream!.Position; }); } [Test] - public async Task StopsOnStringBasedTerminalEvent() + public async Task EnumeratesModelValues() { MockSseClient client = new(); - AsyncClientResultCollection models = client.GetModelsStreamingAsync("[DONE]"); + AsyncClientResultCollection models = client.GetModelsStreamingAsync(_mockContent); - bool empty = true; + int i = 0; await foreach (MockJsonModel model in models) { - empty = false; + Assert.AreEqual(i, model.IntValue); + Assert.AreEqual(i.ToString(), model.StringValue); + + i++; } - Assert.IsNotNull(models); - Assert.AreEqual(models.GetRawResponse().Content.ToString(), "[DONE]"); - Assert.IsTrue(empty); + Assert.AreEqual(i, 3); } [Test] - public async Task CanEnumerateModelValues() + public async Task ModelCollectionDelaysSendingRequest() { MockSseClient client = new(); AsyncClientResultCollection models = client.GetModelsStreamingAsync(_mockContent); + Assert.IsFalse(client.ProtocolMethodCalled); + int i = 0; await foreach (MockJsonModel model in models) { - Assert.AreEqual(model.IntValue, i); - Assert.AreEqual(model.StringValue, i.ToString()); + Assert.AreEqual(i, model.IntValue); + Assert.AreEqual(i.ToString(), model.StringValue); i++; } - Assert.AreEqual(i, 3); + Assert.AreEqual(3, i); + Assert.IsTrue(client.ProtocolMethodCalled); } [Test] @@ -127,6 +123,45 @@ public void ModelCollectionThrowsIfCancelled() }); } + [Test] + public async Task ModelCollectionDisposesStream() + { + MockSseClient client = new(); + AsyncClientResultCollection models = client.GetModelsStreamingAsync(_mockContent); + + await foreach (MockJsonModel model in models) + { + } + + PipelineResponse response = models.GetRawResponse(); + Assert.Throws(() => { var p = response.ContentStream!.Position; }); + } + + [Test] + public void ModelCollectionGetRawResponseThrowsBeforeEnumerated() + { + MockSseClient client = new(); + AsyncClientResultCollection models = client.GetModelsStreamingAsync(_mockContent); + Assert.Throws(() => { PipelineResponse response = models.GetRawResponse(); }); + } + + [Test] + public async Task StopsOnStringBasedTerminalEvent() + { + MockSseClient client = new(); + AsyncClientResultCollection models = client.GetModelsStreamingAsync("[DONE]"); + + bool empty = true; + await foreach (MockJsonModel model in models) + { + empty = false; + } + + Assert.IsNotNull(models); + Assert.AreEqual("[DONE]", models.GetRawResponse().Content.ToString()); + Assert.IsTrue(empty); + } + #region Helpers private readonly string _mockContent = """ diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventFieldTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventFieldTests.cs index 4b3b9db09d38d..8807aaa805b31 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventFieldTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventFieldTests.cs @@ -14,7 +14,7 @@ public void ParsesEventField() string line = "event: event.name"; ServerSentEventField field = new(line); - Assert.AreEqual(field.FieldType, ServerSentEventFieldKind.Event); - Assert.IsTrue(field.Value.Span.SequenceEqual("event.name".AsSpan())); + Assert.AreEqual(ServerSentEventFieldKind.Event, field.FieldType); + Assert.IsTrue("event.name".AsSpan().SequenceEqual(field.Value.Span)); } } diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs index 2365653ffd0f3..ef9cf851d1ba1 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs @@ -6,13 +6,17 @@ using System.IO; using System.Threading; using System.Threading.Tasks; +using ClientModel.Tests.Internal.Mocks; using NUnit.Framework; +using SyncAsyncTestBase = ClientModel.Tests.SyncAsyncTestBase; namespace System.ClientModel.Tests.Convenience; -public class ServerSentEventReaderTests +public class ServerSentEventReaderTests : SyncAsyncTestBase { - // TODO: Test both sync and async + public ServerSentEventReaderTests(bool isAsync) : base(isAsync) + { + } [Test] public async Task GetsEventsFromStream() @@ -21,11 +25,11 @@ public async Task GetsEventsFromStream() using ServerSentEventReader reader = new(contentStream); List events = new(); - ServerSentEvent? ssEvent = await reader.TryGetNextEventAsync(); + ServerSentEvent? ssEvent = await reader.TryGetNextEventSyncOrAsync(IsAsync); while (ssEvent is not null) { events.Add(ssEvent.Value); - ssEvent = await reader.TryGetNextEventAsync(); + ssEvent = await reader.TryGetNextEventSyncOrAsync(IsAsync); } Assert.AreEqual(events.Count, 4); @@ -33,12 +37,12 @@ public async Task GetsEventsFromStream() for (int i = 0; i < 3; i++) { ServerSentEvent sse = events[i]; - Assert.IsTrue(sse.EventType.AsSpan().SequenceEqual($"event.{i}".AsSpan())); - Assert.IsTrue(sse.Data.AsSpan().SequenceEqual($"{{ \"id\": \"{i}\", \"object\": {i} }}".AsSpan())); + Assert.AreEqual($"event.{i}", sse.EventType); + Assert.AreEqual($"{{ \"id\": \"{i}\", \"object\": {i} }}", sse.Data); } - Assert.IsTrue(events[3].EventType.AsSpan().SequenceEqual("done".AsSpan())); - Assert.IsTrue(events[3].Data.AsSpan().SequenceEqual("[DONE]".AsSpan())); + Assert.AreEqual("done", events[3].EventType); + Assert.AreEqual("[DONE]", events[3].Data); } [Test] @@ -47,7 +51,7 @@ public async Task HandlesNullLine() Stream contentStream = BinaryData.FromString(string.Empty).ToStream(); using ServerSentEventReader reader = new(contentStream); - ServerSentEvent? ssEvent = await reader.TryGetNextEventAsync(); + ServerSentEvent? ssEvent = await reader.TryGetNextEventSyncOrAsync(IsAsync); Assert.IsNull(ssEvent); } @@ -57,7 +61,7 @@ public async Task DiscardsCommentLine() Stream contentStream = BinaryData.FromString(": comment").ToStream(); using ServerSentEventReader reader = new(contentStream); - ServerSentEvent? ssEvent = await reader.TryGetNextEventAsync(); + ServerSentEvent? ssEvent = await reader.TryGetNextEventSyncOrAsync(IsAsync); Assert.IsNull(ssEvent); } @@ -71,7 +75,7 @@ public async Task HandlesIgnoreLine() """).ToStream(); using ServerSentEventReader reader = new(contentStream); - ServerSentEvent? sse = await reader.TryGetNextEventAsync(); + ServerSentEvent? sse = await reader.TryGetNextEventSyncOrAsync(IsAsync); Assert.IsNull(sse); } @@ -82,11 +86,11 @@ public async Task HandlesDoneEvent() Stream contentStream = BinaryData.FromString("event: stop\ndata: ~stop~\n\n").ToStream(); using ServerSentEventReader reader = new(contentStream); - ServerSentEvent? sse = await reader.TryGetNextEventAsync(); + ServerSentEvent? sse = await reader.TryGetNextEventSyncOrAsync(IsAsync); Assert.IsNotNull(sse); - Assert.IsTrue(sse.Value.EventType.AsSpan().SequenceEqual("stop".AsSpan())); - Assert.IsTrue(sse.Value.Data.AsSpan().SequenceEqual("~stop~".AsSpan())); + Assert.AreEqual("stop", sse.Value.EventType); + Assert.AreEqual("~stop~", sse.Value.Data); Assert.IsNull(sse.Value.Id); Assert.IsNull(sse.Value.ReconnectionTime); } @@ -95,7 +99,6 @@ public async Task HandlesDoneEvent() public async Task ConcatenatesDataLines() { Stream contentStream = BinaryData.FromString(""" - event: event data: YHOO data: +2 data: 10 @@ -104,11 +107,29 @@ public async Task ConcatenatesDataLines() """).ToStream(); using ServerSentEventReader reader = new(contentStream); - ServerSentEvent? sse = await reader.TryGetNextEventAsync(); + ServerSentEvent? sse = await reader.TryGetNextEventSyncOrAsync(IsAsync); Assert.IsNotNull(sse); - Assert.IsTrue(sse.Value.EventType.AsSpan().SequenceEqual("event".AsSpan())); - Assert.IsTrue(sse.Value.Data.AsSpan().SequenceEqual("YHOO\n+2\n10".AsSpan())); + Assert.AreEqual("YHOO\n+2\n10", sse.Value.Data); + Assert.IsNull(sse.Value.Id); + Assert.IsNull(sse.Value.ReconnectionTime); + } + + [Test] + public async Task DefaultsEventTypeToMessage() + { + Stream contentStream = BinaryData.FromString(""" + data: data + + + """).ToStream(); + using ServerSentEventReader reader = new(contentStream); + + ServerSentEvent? sse = await reader.TryGetNextEventSyncOrAsync(IsAsync); + + Assert.IsNotNull(sse); + Assert.AreEqual("message", sse.Value.EventType); + Assert.AreEqual("data", sse.Value.Data); Assert.IsNull(sse.Value.Id); Assert.IsNull(sse.Value.ReconnectionTime); } @@ -134,22 +155,22 @@ public async Task SecondTestCaseFromSpec() List events = new(); - ServerSentEvent? sse = await reader.TryGetNextEventAsync(); + ServerSentEvent? sse = await reader.TryGetNextEventSyncOrAsync(IsAsync); while (sse is not null) { events.Add(sse.Value); - sse = await reader.TryGetNextEventAsync(); + sse = await reader.TryGetNextEventSyncOrAsync(IsAsync); } Assert.AreEqual(3, events.Count); - Assert.IsTrue(events[0].Data.AsSpan().SequenceEqual("first event".AsSpan())); - Assert.IsTrue(events[0].Id.AsSpan().SequenceEqual("1".AsSpan())); + Assert.AreEqual("first event", events[0].Data); + Assert.AreEqual("1", events[0].Id); - Assert.IsTrue(events[1].Data.AsSpan().SequenceEqual("second event".AsSpan())); + Assert.AreEqual("second event", events[1].Data); Assert.IsNull(events[1].Id); - Assert.IsTrue(events[2].Data.AsSpan().SequenceEqual(" third event".AsSpan())); + Assert.AreEqual(" third event", events[2].Data); Assert.IsNull(events[2].Id); } @@ -169,17 +190,16 @@ public async Task ThirdSpecTestCase() List events = new(); - ServerSentEvent? sse = await reader.TryGetNextEventAsync(); + ServerSentEvent? sse = await reader.TryGetNextEventSyncOrAsync(IsAsync); while (sse is not null) { events.Add(sse.Value); - sse = await reader.TryGetNextEventAsync(); + sse = await reader.TryGetNextEventSyncOrAsync(IsAsync); } Assert.AreEqual(2, events.Count); - Assert.AreEqual(0, events[0].Data.Length); - Assert.IsTrue(events[1].Data.AsSpan().SequenceEqual("\n".AsSpan())); + Assert.AreEqual("\n", events[1].Data); } [Test] @@ -197,11 +217,11 @@ public async Task FourthSpecTestCase() List events = new(); - ServerSentEvent? sse = await reader.TryGetNextEventAsync(); + ServerSentEvent? sse = await reader.TryGetNextEventSyncOrAsync(IsAsync); while (sse is not null) { events.Add(sse.Value); - sse = await reader.TryGetNextEventAsync(); + sse = await reader.TryGetNextEventSyncOrAsync(IsAsync); } Assert.AreEqual(2, events.Count); @@ -223,7 +243,7 @@ public void ThrowsIfCancelled() #region Helpers // Note: raw string literal quirk removes \n from final line. - private string _mockContent = """ + private readonly string _mockContent = """ event: event.0 data: { "id": "0", "object": 0 } diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventTests.cs index 2ac586d87e069..2c4e13f2695d6 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventTests.cs @@ -2,29 +2,21 @@ // Licensed under the MIT License. using System.ClientModel.Internal; -using System.Collections.Generic; using NUnit.Framework; namespace System.ClientModel.Tests.Convenience; public class ServerSentEventTests { - //[Test] - //public void SetsPropertiesFromFields() - //{ - // string eventLine = "event: event.name"; - // string dataLine = """data: {"id":"a","object":"value"}"""; + [Test] + public void ParsesReconnectionTime() + { + string retryTimeInMillis = "2500"; + ServerSentEvent sse = new("message", "data", id: default, retryTimeInMillis); - // List fields = new() { - // new ServerSentEventField(eventLine), - // new ServerSentEventField(dataLine) - // }; - - // ServerSentEvent ssEvent = new(fields); - - // Assert.IsNull(ssEvent.ReconnectionTime); - // Assert.IsTrue(ssEvent.EventType.AsSpan().SequenceEqual("event.name".AsSpan())); - // Assert.IsTrue(ssEvent.Data.AsSpan().SequenceEqual("""{"id":"a","object":"value"}""".AsSpan())); - // Assert.AreEqual(ssEvent.Id.Length, 0); - //} + Assert.AreEqual("message", sse.EventType); + Assert.AreEqual("data", sse.Data); + Assert.IsNull(sse.Id); + Assert.AreEqual(new TimeSpan(0, 0, 0, 2, 500), sse.ReconnectionTime); + } } diff --git a/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClient.cs b/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClient.cs index 7495017b2ce8f..a0cdcf442e2ac 100644 --- a/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClient.cs +++ b/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClient.cs @@ -61,7 +61,6 @@ public override IAsyncEnumerator GetAsyncEnumerator(CancellationT { async Task getResultAsync() { - // TODO: simulate async correctly await Task.Delay(0, cancellationToken); return _protocolMethod(_content, /*options:*/ default); } @@ -103,24 +102,8 @@ async ValueTask IAsyncEnumerator.MoveNextAsync() } _cancellationToken.ThrowIfCancellationRequested(); - - // TODO: refactor for clarity - // Lazily initialize - if (_events is null) - { - ClientResult result = await _getResultAsync().ConfigureAwait(false); - PipelineResponse response = result.GetRawResponse(); - _enumerable.SetRawResponse(response); - - if (response.ContentStream is null) - { - throw new ArgumentException("Unable to create result from response with null ContentStream", nameof(response)); - } - - AsyncServerSentEventEnumerable enumerable = new(response.ContentStream); - _events = enumerable.GetAsyncEnumerator(_cancellationToken); - _started = true; - } + _events ??= await CreateEventEnumeratorAsync().ConfigureAwait(false); + _started = true; if (await _events.MoveNextAsync().ConfigureAwait(false)) { @@ -131,7 +114,6 @@ async ValueTask IAsyncEnumerator.MoveNextAsync() } BinaryData data = BinaryData.FromString(_events.Current.Data); - MockJsonModel? model = ModelReaderWriter.Read(data) ?? throw new JsonException($"Failed to deserialize expected type MockJsonModel from sse data payload '{_events.Current.Data}'."); @@ -143,7 +125,50 @@ async ValueTask IAsyncEnumerator.MoveNextAsync() return false; } - ValueTask IAsyncDisposable.DisposeAsync() => new(); + private async Task> CreateEventEnumeratorAsync() + { + ClientResult result = await _getResultAsync().ConfigureAwait(false); + PipelineResponse response = result.GetRawResponse(); + _enumerable.SetRawResponse(response); + + if (response.ContentStream is null) + { + throw new ArgumentException("Unable to create result from response with null ContentStream", nameof(response)); + } + + AsyncServerSentEventEnumerable enumerable = new(response.ContentStream); + return enumerable.GetAsyncEnumerator(_cancellationToken); + } + + public async ValueTask DisposeAsync() + { + await DisposeAsyncCore().ConfigureAwait(false); + + GC.SuppressFinalize(this); + } + + private async ValueTask DisposeAsyncCore() + { + if (_events is not null) + { + // Disposing the sse enumerator should be a no-op. + await _events.DisposeAsync().ConfigureAwait(false); + _events = null; + + // But we also need to dispose the response content stream + // so we don't leave the unbuffered network stream open. + PipelineResponse response = _enumerable.GetRawResponse(); + + if (response.ContentStream is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync().ConfigureAwait(false); + } + else if (response.ContentStream is IDisposable disposable) + { + disposable.Dispose(); + } + } + } } } } diff --git a/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSyncAsyncInternalExtensions.cs b/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSyncAsyncInternalExtensions.cs new file mode 100644 index 0000000000000..8ed090f78a226 --- /dev/null +++ b/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSyncAsyncInternalExtensions.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ClientModel.Internal; +using System.Threading.Tasks; + +namespace ClientModel.Tests.Internal.Mocks; + +internal static class MockSyncAsyncInternalExtensions +{ + public static async Task TryGetNextEventSyncOrAsync(this ServerSentEventReader reader, bool isAsync) + { + if (isAsync) + { + return await reader.TryGetNextEventAsync().ConfigureAwait(false); + } + else + { + return reader.TryGetNextEvent(); + } + } +} From 3bf665b3aa5910a326742d15898dbe9e36af3752 Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Thu, 9 May 2024 17:39:52 -0700 Subject: [PATCH 39/45] add default constructor to ClientResult --- .../api/System.ClientModel.net6.0.cs | 13 +++++++------ .../api/System.ClientModel.netstandard2.0.cs | 13 +++++++------ .../Convenience/AsyncClientResultCollectionOfT.cs | 2 +- .../src/Convenience/ClientResult.cs | 13 ++++++++++++- .../src/Convenience/ClientResultCollectionOfT.cs | 2 +- 5 files changed, 28 insertions(+), 15 deletions(-) diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs index 8c80026f6520e..6eb71e0871f22 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs @@ -9,8 +9,8 @@ public void Update(string key) { } } public abstract partial class AsyncClientResultCollection : System.ClientModel.ClientResult, System.Collections.Generic.IAsyncEnumerable { - protected internal AsyncClientResultCollection() : base (default(System.ClientModel.Primitives.PipelineResponse)) { } - protected internal AsyncClientResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } + protected internal AsyncClientResultCollection() { } + protected internal AsyncClientResultCollection(System.ClientModel.Primitives.PipelineResponse response) { } public abstract System.Collections.Generic.IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); } public abstract partial class BinaryContent : System.IDisposable @@ -26,7 +26,8 @@ protected BinaryContent() { } } public partial class ClientResult { - protected ClientResult(System.ClientModel.Primitives.PipelineResponse? response) { } + protected ClientResult() { } + protected ClientResult(System.ClientModel.Primitives.PipelineResponse response) { } public static System.ClientModel.ClientResult FromOptionalValue(T? value, System.ClientModel.Primitives.PipelineResponse response) { throw null; } public static System.ClientModel.ClientResult FromResponse(System.ClientModel.Primitives.PipelineResponse response) { throw null; } public static System.ClientModel.ClientResult FromValue(T value, System.ClientModel.Primitives.PipelineResponse response) { throw null; } @@ -35,8 +36,8 @@ protected void SetRawResponse(System.ClientModel.Primitives.PipelineResponse res } public abstract partial class ClientResultCollection : System.ClientModel.ClientResult, System.Collections.Generic.IEnumerable, System.Collections.IEnumerable { - protected internal ClientResultCollection() : base (default(System.ClientModel.Primitives.PipelineResponse)) { } - protected internal ClientResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } + protected internal ClientResultCollection() { } + protected internal ClientResultCollection(System.ClientModel.Primitives.PipelineResponse response) { } public abstract System.Collections.Generic.IEnumerator GetEnumerator(); System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { throw null; } } @@ -50,7 +51,7 @@ public ClientResultException(string message, System.ClientModel.Primitives.Pipel } public partial class ClientResult : System.ClientModel.ClientResult { - protected internal ClientResult(T value, System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } + protected internal ClientResult(T value, System.ClientModel.Primitives.PipelineResponse response) { } public virtual T Value { get { throw null; } } public static implicit operator T (System.ClientModel.ClientResult result) { throw null; } } diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs index 71c8abf89cf29..fbef0ee37a654 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs @@ -9,8 +9,8 @@ public void Update(string key) { } } public abstract partial class AsyncClientResultCollection : System.ClientModel.ClientResult, System.Collections.Generic.IAsyncEnumerable { - protected internal AsyncClientResultCollection() : base (default(System.ClientModel.Primitives.PipelineResponse)) { } - protected internal AsyncClientResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } + protected internal AsyncClientResultCollection() { } + protected internal AsyncClientResultCollection(System.ClientModel.Primitives.PipelineResponse response) { } public abstract System.Collections.Generic.IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); } public abstract partial class BinaryContent : System.IDisposable @@ -26,7 +26,8 @@ protected BinaryContent() { } } public partial class ClientResult { - protected ClientResult(System.ClientModel.Primitives.PipelineResponse? response) { } + protected ClientResult() { } + protected ClientResult(System.ClientModel.Primitives.PipelineResponse response) { } public static System.ClientModel.ClientResult FromOptionalValue(T? value, System.ClientModel.Primitives.PipelineResponse response) { throw null; } public static System.ClientModel.ClientResult FromResponse(System.ClientModel.Primitives.PipelineResponse response) { throw null; } public static System.ClientModel.ClientResult FromValue(T value, System.ClientModel.Primitives.PipelineResponse response) { throw null; } @@ -35,8 +36,8 @@ protected void SetRawResponse(System.ClientModel.Primitives.PipelineResponse res } public abstract partial class ClientResultCollection : System.ClientModel.ClientResult, System.Collections.Generic.IEnumerable, System.Collections.IEnumerable { - protected internal ClientResultCollection() : base (default(System.ClientModel.Primitives.PipelineResponse)) { } - protected internal ClientResultCollection(System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } + protected internal ClientResultCollection() { } + protected internal ClientResultCollection(System.ClientModel.Primitives.PipelineResponse response) { } public abstract System.Collections.Generic.IEnumerator GetEnumerator(); System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { throw null; } } @@ -50,7 +51,7 @@ public ClientResultException(string message, System.ClientModel.Primitives.Pipel } public partial class ClientResult : System.ClientModel.ClientResult { - protected internal ClientResult(T value, System.ClientModel.Primitives.PipelineResponse response) : base (default(System.ClientModel.Primitives.PipelineResponse)) { } + protected internal ClientResult(T value, System.ClientModel.Primitives.PipelineResponse response) { } public virtual T Value { get { throw null; } } public static implicit operator T (System.ClientModel.ClientResult result) { throw null; } } diff --git a/sdk/core/System.ClientModel/src/Convenience/AsyncClientResultCollectionOfT.cs b/sdk/core/System.ClientModel/src/Convenience/AsyncClientResultCollectionOfT.cs index 42706a7e70c3a..ed16965f14655 100644 --- a/sdk/core/System.ClientModel/src/Convenience/AsyncClientResultCollectionOfT.cs +++ b/sdk/core/System.ClientModel/src/Convenience/AsyncClientResultCollectionOfT.cs @@ -13,7 +13,7 @@ public abstract class AsyncClientResultCollection : ClientResult, IAsyncEnume // Constructor overload for collection implementations that postpone // sending a request until GetAsyncEnumerator is called. This will typically // be used by collections returned from client convenience methods. - protected internal AsyncClientResultCollection() : base(default) + protected internal AsyncClientResultCollection() : base() { } diff --git a/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs b/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs index 5a737eb511326..e45145edf16b7 100644 --- a/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs +++ b/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs @@ -13,13 +13,24 @@ public class ClientResult { private PipelineResponse? _response; + /// + /// Create a new instance of . + /// + /// If no is provided when the + /// instance is created, it is expected that + /// a derived type will call + /// prior to a user calling . + protected ClientResult() + { + } + /// /// Create a new instance of from a service /// response. /// /// The received /// from the service. - protected ClientResult(PipelineResponse? response) + protected ClientResult(PipelineResponse response) { _response = response; } diff --git a/sdk/core/System.ClientModel/src/Convenience/ClientResultCollectionOfT.cs b/sdk/core/System.ClientModel/src/Convenience/ClientResultCollectionOfT.cs index 4a6622080d5be..e6e55122ea307 100644 --- a/sdk/core/System.ClientModel/src/Convenience/ClientResultCollectionOfT.cs +++ b/sdk/core/System.ClientModel/src/Convenience/ClientResultCollectionOfT.cs @@ -13,7 +13,7 @@ public abstract class ClientResultCollection : ClientResult, IEnumerable // Constructor overload for collection implementations that postpone // sending a request until GetAsyncEnumerator is called. This will typically // be used by collections returned from client convenience methods. - protected internal ClientResultCollection() : base(default) + protected internal ClientResultCollection() : base() { } From f87933e7ec2fe86a3afd8b48032059cd031004fe Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Thu, 9 May 2024 18:00:37 -0700 Subject: [PATCH 40/45] more tidy-up --- .../src/Convenience/ClientResult.cs | 2 + .../src/Internal/SSE/ServerSentEvent.cs | 3 +- .../src/Internal/SSE/ServerSentEventReader.cs | 70 +++---------------- .../tests/Convenience/ClientResultTests.cs | 45 ++++++++++++ .../tests/TestFramework/Mocks/MockClient.cs | 54 -------------- .../SSE/ServerSentEventReaderTests.cs | 22 +++--- 6 files changed, 70 insertions(+), 126 deletions(-) delete mode 100644 sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs diff --git a/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs b/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs index e45145edf16b7..0dedda0117104 100644 --- a/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs +++ b/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs @@ -32,6 +32,8 @@ protected ClientResult() /// from the service. protected ClientResult(PipelineResponse response) { + Argument.AssertNotNull(response, nameof(response)); + _response = response; } diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs index 04b46f65e3bf7..333fe3ee2833c 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs @@ -23,8 +23,7 @@ public ServerSentEvent(string type, string data, string? id, string? retry) EventType = type; Data = data; Id = id; - ReconnectionTime = retry is null ? - default : + ReconnectionTime = retry is null ? null : int.TryParse(retry, out int time) ? TimeSpan.FromMilliseconds(time) : null; } } diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs index 5181487dc8019..76b0273da447d 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs @@ -10,15 +10,16 @@ namespace System.ClientModel.Internal; // SSE specification: https://html.spec.whatwg.org/multipage/server-sent-events.html -internal sealed class ServerSentEventReader : IDisposable, IAsyncDisposable +internal sealed class ServerSentEventReader { - private Stream? _stream; - private StreamReader? _reader; - - public int? LastEventId { get; private set; } + private readonly Stream? _stream; + private readonly StreamReader? _reader; public ServerSentEventReader(Stream stream) { + // Creator of the reader has responsibility for disposing the stream + // passed to the reader constructor. + _stream = stream; _reader = new StreamReader(stream); } @@ -160,6 +161,8 @@ private struct PendingEvent public ServerSentEvent ToEvent() { + Debug.Assert(DataLength > 0); + // Per spec, if event type buffer is empty, set event.type to "message". string type = EventTypeField.HasValue ? EventTypeField.Value.Value.ToString() : @@ -171,75 +174,24 @@ public ServerSentEvent ToEvent() string? retry = RetryField.HasValue && RetryField.Value.Value.Length > 0 ? RetryField.Value.Value.ToString() : default; - Debug.Assert(DataLength > 0); - Memory buffer = new(new char[DataLength]); int curr = 0; - foreach (ServerSentEventField field in DataFields) { Debug.Assert(field.FieldType == ServerSentEventFieldKind.Data); field.Value.Span.CopyTo(buffer.Span.Slice(curr)); + + // Per spec, append trailing LF to each data field value. buffer.Span[curr + field.Value.Length] = LF; curr += field.Value.Length + 1; } - // Per spec, remove trailing LF + // Per spec, remove trailing LF from concatenated data fields. string data = buffer.Slice(0, buffer.Length - 1).ToString(); return new ServerSentEvent(type, data, id, retry); } } - - public void Dispose() - { - Dispose(disposing: true); - GC.SuppressFinalize(this); - } - - private void Dispose(bool disposing) - { - if (disposing) - { - _reader?.Dispose(); - _reader = null; - - _stream?.Dispose(); - _stream = null; - } - } - - public async ValueTask DisposeAsync() - { - await DisposeAsyncCore().ConfigureAwait(false); - - Dispose(disposing: false); - GC.SuppressFinalize(this); - } - - private async ValueTask DisposeAsyncCore() - { - if (_reader is IAsyncDisposable reader) - { - await reader.DisposeAsync().ConfigureAwait(false); - } - else - { - _reader?.Dispose(); - } - - if (_stream is IAsyncDisposable stream) - { - await stream.DisposeAsync().ConfigureAwait(false); - } - else - { - _stream?.Dispose(); - } - - _reader = null; - _stream = null; - } } diff --git a/sdk/core/System.ClientModel/tests/Convenience/ClientResultTests.cs b/sdk/core/System.ClientModel/tests/Convenience/ClientResultTests.cs index 381404a0399fb..629f1ec27f2b1 100644 --- a/sdk/core/System.ClientModel/tests/Convenience/ClientResultTests.cs +++ b/sdk/core/System.ClientModel/tests/Convenience/ClientResultTests.cs @@ -257,6 +257,51 @@ public DerivedClientResult(T value, PipelineResponse response) : base(value, res } } + internal class MockClient + { + public virtual ClientResult GetModel(int intValue, string stringValue) + { + MockPipelineResponse response = new(200); + MockJsonModel model = new MockJsonModel(intValue, stringValue); + return ClientResult.FromValue(model, response); + } + + public virtual ClientResult GetOptionalModel(int intValue, string stringValue, bool hasValue) + { + if (hasValue) + { + MockPipelineResponse response = new(200); + MockJsonModel model = new MockJsonModel(intValue, stringValue); + return ClientResult.FromOptionalValue(model, response); + } + else + { + MockPipelineResponse response = new(404); + return ClientResult.FromOptionalValue(default, response); + } + } + + public virtual ClientResult GetCount(int count) + { + MockPipelineResponse response = new(200); + return ClientResult.FromValue(count, response); + } + + public virtual ClientResult GetOptionalCount(int count, bool hasValue) + { + if (hasValue) + { + MockPipelineResponse response = new(200); + return ClientResult.FromOptionalValue(count, response); + } + else + { + MockPipelineResponse response = new(404); + return ClientResult.FromOptionalValue(default, response); + } + } + } + internal class CastableClientResult : ClientResult { protected internal CastableClientResult(T value, PipelineResponse response) : base(value, response) diff --git a/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs b/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs deleted file mode 100644 index 6dba7dd733db3..0000000000000 --- a/sdk/core/System.ClientModel/tests/TestFramework/Mocks/MockClient.cs +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System.ClientModel; -using Azure.Core.TestFramework; - -namespace ClientModel.Tests.Mocks; - -public class MockClient -{ - public bool StreamingProtocolMethodCalled { get; private set; } - - public virtual ClientResult GetModel(int intValue, string stringValue) - { - MockPipelineResponse response = new(200); - MockJsonModel model = new MockJsonModel(intValue, stringValue); - return ClientResult.FromValue(model, response); - } - - public virtual ClientResult GetOptionalModel(int intValue, string stringValue, bool hasValue) - { - if (hasValue) - { - MockPipelineResponse response = new(200); - MockJsonModel model = new MockJsonModel(intValue, stringValue); - return ClientResult.FromOptionalValue(model, response); - } - else - { - MockPipelineResponse response = new(404); - return ClientResult.FromOptionalValue(default, response); - } - } - - public virtual ClientResult GetCount(int count) - { - MockPipelineResponse response = new(200); - return ClientResult.FromValue(count, response); - } - - public virtual ClientResult GetOptionalCount(int count, bool hasValue) - { - if (hasValue) - { - MockPipelineResponse response = new(200); - return ClientResult.FromOptionalValue(count, response); - } - else - { - MockPipelineResponse response = new(404); - return ClientResult.FromOptionalValue(default, response); - } - } -} diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs index ef9cf851d1ba1..07aac8f2aff2f 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs @@ -22,7 +22,7 @@ public ServerSentEventReaderTests(bool isAsync) : base(isAsync) public async Task GetsEventsFromStream() { Stream contentStream = BinaryData.FromString(_mockContent).ToStream(); - using ServerSentEventReader reader = new(contentStream); + ServerSentEventReader reader = new(contentStream); List events = new(); ServerSentEvent? ssEvent = await reader.TryGetNextEventSyncOrAsync(IsAsync); @@ -49,7 +49,7 @@ public async Task GetsEventsFromStream() public async Task HandlesNullLine() { Stream contentStream = BinaryData.FromString(string.Empty).ToStream(); - using ServerSentEventReader reader = new(contentStream); + ServerSentEventReader reader = new(contentStream); ServerSentEvent? ssEvent = await reader.TryGetNextEventSyncOrAsync(IsAsync); Assert.IsNull(ssEvent); @@ -59,7 +59,7 @@ public async Task HandlesNullLine() public async Task DiscardsCommentLine() { Stream contentStream = BinaryData.FromString(": comment").ToStream(); - using ServerSentEventReader reader = new(contentStream); + ServerSentEventReader reader = new(contentStream); ServerSentEvent? ssEvent = await reader.TryGetNextEventSyncOrAsync(IsAsync); Assert.IsNull(ssEvent); @@ -73,7 +73,7 @@ public async Task HandlesIgnoreLine() """).ToStream(); - using ServerSentEventReader reader = new(contentStream); + ServerSentEventReader reader = new(contentStream); ServerSentEvent? sse = await reader.TryGetNextEventSyncOrAsync(IsAsync); @@ -84,7 +84,7 @@ public async Task HandlesIgnoreLine() public async Task HandlesDoneEvent() { Stream contentStream = BinaryData.FromString("event: stop\ndata: ~stop~\n\n").ToStream(); - using ServerSentEventReader reader = new(contentStream); + ServerSentEventReader reader = new(contentStream); ServerSentEvent? sse = await reader.TryGetNextEventSyncOrAsync(IsAsync); @@ -105,7 +105,7 @@ public async Task ConcatenatesDataLines() """).ToStream(); - using ServerSentEventReader reader = new(contentStream); + ServerSentEventReader reader = new(contentStream); ServerSentEvent? sse = await reader.TryGetNextEventSyncOrAsync(IsAsync); @@ -123,7 +123,7 @@ public async Task DefaultsEventTypeToMessage() """).ToStream(); - using ServerSentEventReader reader = new(contentStream); + ServerSentEventReader reader = new(contentStream); ServerSentEvent? sse = await reader.TryGetNextEventSyncOrAsync(IsAsync); @@ -151,7 +151,7 @@ public async Task SecondTestCaseFromSpec() """).ToStream(); - using ServerSentEventReader reader = new(contentStream); + ServerSentEventReader reader = new(contentStream); List events = new(); @@ -186,7 +186,7 @@ public async Task ThirdSpecTestCase() data: """).ToStream(); - using ServerSentEventReader reader = new(contentStream); + ServerSentEventReader reader = new(contentStream); List events = new(); @@ -213,7 +213,7 @@ public async Task FourthSpecTestCase() """).ToStream(); - using ServerSentEventReader reader = new(contentStream); + ServerSentEventReader reader = new(contentStream); List events = new(); @@ -234,7 +234,7 @@ public void ThrowsIfCancelled() CancellationToken token = new(true); using Stream contentStream = BinaryData.FromString(_mockContent).ToStream(); - using ServerSentEventReader reader = new(contentStream); + ServerSentEventReader reader = new(contentStream); Assert.ThrowsAsync(async () => await reader.TryGetNextEventAsync(token)); From 43dbbc2efba8a241c2d53274146907051f3ec409 Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Fri, 10 May 2024 11:37:36 -0700 Subject: [PATCH 41/45] rename and add refdocs --- .../api/System.ClientModel.net6.0.cs | 20 ++++----- .../api/System.ClientModel.netstandard2.0.cs | 20 ++++----- .../AsyncClientResultCollectionOfT.cs | 29 ------------ .../Convenience/AsyncResultCollectionOfT.cs | 43 ++++++++++++++++++ .../src/Convenience/ClientResult.cs | 2 +- .../Convenience/ClientResultCollectionOfT.cs | 31 ------------- .../src/Convenience/ResultCollectionOfT.cs | 45 +++++++++++++++++++ .../SSE/ClientResultCollectionTests.cs | 12 ++--- .../TestFramework/Mocks/MockSseClient.cs | 4 +- .../Mocks/MockSseClientExtensions.cs | 4 +- 10 files changed, 119 insertions(+), 91 deletions(-) delete mode 100644 sdk/core/System.ClientModel/src/Convenience/AsyncClientResultCollectionOfT.cs create mode 100644 sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs delete mode 100644 sdk/core/System.ClientModel/src/Convenience/ClientResultCollectionOfT.cs create mode 100644 sdk/core/System.ClientModel/src/Convenience/ResultCollectionOfT.cs diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs index 6eb71e0871f22..a7fe9b8604a5f 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.net6.0.cs @@ -7,10 +7,10 @@ public ApiKeyCredential(string key) { } public static implicit operator System.ClientModel.ApiKeyCredential (string key) { throw null; } public void Update(string key) { } } - public abstract partial class AsyncClientResultCollection : System.ClientModel.ClientResult, System.Collections.Generic.IAsyncEnumerable + public abstract partial class AsyncResultCollection : System.ClientModel.ClientResult, System.Collections.Generic.IAsyncEnumerable { - protected internal AsyncClientResultCollection() { } - protected internal AsyncClientResultCollection(System.ClientModel.Primitives.PipelineResponse response) { } + protected internal AsyncResultCollection() { } + protected internal AsyncResultCollection(System.ClientModel.Primitives.PipelineResponse response) { } public abstract System.Collections.Generic.IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); } public abstract partial class BinaryContent : System.IDisposable @@ -34,13 +34,6 @@ protected ClientResult(System.ClientModel.Primitives.PipelineResponse response) public System.ClientModel.Primitives.PipelineResponse GetRawResponse() { throw null; } protected void SetRawResponse(System.ClientModel.Primitives.PipelineResponse response) { } } - public abstract partial class ClientResultCollection : System.ClientModel.ClientResult, System.Collections.Generic.IEnumerable, System.Collections.IEnumerable - { - protected internal ClientResultCollection() { } - protected internal ClientResultCollection(System.ClientModel.Primitives.PipelineResponse response) { } - public abstract System.Collections.Generic.IEnumerator GetEnumerator(); - System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { throw null; } - } public partial class ClientResultException : System.Exception { public ClientResultException(System.ClientModel.Primitives.PipelineResponse response, System.Exception? innerException = null) { } @@ -55,6 +48,13 @@ protected internal ClientResult(T value, System.ClientModel.Primitives.PipelineR public virtual T Value { get { throw null; } } public static implicit operator T (System.ClientModel.ClientResult result) { throw null; } } + public abstract partial class ResultCollection : System.ClientModel.ClientResult, System.Collections.Generic.IEnumerable, System.Collections.IEnumerable + { + protected internal ResultCollection() { } + protected internal ResultCollection(System.ClientModel.Primitives.PipelineResponse response) { } + public abstract System.Collections.Generic.IEnumerator GetEnumerator(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { throw null; } + } } namespace System.ClientModel.Primitives { diff --git a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs index fbef0ee37a654..15e1f1f75fae6 100644 --- a/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs +++ b/sdk/core/System.ClientModel/api/System.ClientModel.netstandard2.0.cs @@ -7,10 +7,10 @@ public ApiKeyCredential(string key) { } public static implicit operator System.ClientModel.ApiKeyCredential (string key) { throw null; } public void Update(string key) { } } - public abstract partial class AsyncClientResultCollection : System.ClientModel.ClientResult, System.Collections.Generic.IAsyncEnumerable + public abstract partial class AsyncResultCollection : System.ClientModel.ClientResult, System.Collections.Generic.IAsyncEnumerable { - protected internal AsyncClientResultCollection() { } - protected internal AsyncClientResultCollection(System.ClientModel.Primitives.PipelineResponse response) { } + protected internal AsyncResultCollection() { } + protected internal AsyncResultCollection(System.ClientModel.Primitives.PipelineResponse response) { } public abstract System.Collections.Generic.IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); } public abstract partial class BinaryContent : System.IDisposable @@ -34,13 +34,6 @@ protected ClientResult(System.ClientModel.Primitives.PipelineResponse response) public System.ClientModel.Primitives.PipelineResponse GetRawResponse() { throw null; } protected void SetRawResponse(System.ClientModel.Primitives.PipelineResponse response) { } } - public abstract partial class ClientResultCollection : System.ClientModel.ClientResult, System.Collections.Generic.IEnumerable, System.Collections.IEnumerable - { - protected internal ClientResultCollection() { } - protected internal ClientResultCollection(System.ClientModel.Primitives.PipelineResponse response) { } - public abstract System.Collections.Generic.IEnumerator GetEnumerator(); - System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { throw null; } - } public partial class ClientResultException : System.Exception { public ClientResultException(System.ClientModel.Primitives.PipelineResponse response, System.Exception? innerException = null) { } @@ -55,6 +48,13 @@ protected internal ClientResult(T value, System.ClientModel.Primitives.PipelineR public virtual T Value { get { throw null; } } public static implicit operator T (System.ClientModel.ClientResult result) { throw null; } } + public abstract partial class ResultCollection : System.ClientModel.ClientResult, System.Collections.Generic.IEnumerable, System.Collections.IEnumerable + { + protected internal ResultCollection() { } + protected internal ResultCollection(System.ClientModel.Primitives.PipelineResponse response) { } + public abstract System.Collections.Generic.IEnumerator GetEnumerator(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { throw null; } + } } namespace System.ClientModel.Primitives { diff --git a/sdk/core/System.ClientModel/src/Convenience/AsyncClientResultCollectionOfT.cs b/sdk/core/System.ClientModel/src/Convenience/AsyncClientResultCollectionOfT.cs deleted file mode 100644 index ed16965f14655..0000000000000 --- a/sdk/core/System.ClientModel/src/Convenience/AsyncClientResultCollectionOfT.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System.ClientModel.Primitives; -using System.Collections.Generic; -using System.Threading; - -namespace System.ClientModel; - -#pragma warning disable CS1591 // public XML comments -public abstract class AsyncClientResultCollection : ClientResult, IAsyncEnumerable -{ - // Constructor overload for collection implementations that postpone - // sending a request until GetAsyncEnumerator is called. This will typically - // be used by collections returned from client convenience methods. - protected internal AsyncClientResultCollection() : base() - { - } - - // Constructor overload for collection implementations where the service - // has returned a response. This will typically be used by collections - // created from the return result of a client's protocol method. - protected internal AsyncClientResultCollection(PipelineResponse response) : base(response) - { - } - - public abstract IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default); -} -#pragma warning restore CS1591 // public XML comments diff --git a/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs b/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs new file mode 100644 index 0000000000000..da0f411799f0e --- /dev/null +++ b/sdk/core/System.ClientModel/src/Convenience/AsyncResultCollectionOfT.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Threading; + +namespace System.ClientModel; + +/// +/// Represents a collection of results returned from a cloud service operation. +/// +public abstract class AsyncResultCollection : ClientResult, IAsyncEnumerable +{ + /// + /// Create a new instance of . + /// + /// If no is provided when the + /// instance is created, it is expected that + /// a derived type will call + /// prior to a user calling . + /// This constructor is indended for use by collection implementations that + /// postpone sending a request until + /// is called. Such implementations will typically be returned from client + /// convenience methods so that callers of the methods don't need to + /// dispose the return value. + protected internal AsyncResultCollection() : base() + { + } + + /// + /// Create a new instance of . + /// + /// The holding the + /// items in the collection, or the first set of the items in the collection. + /// + protected internal AsyncResultCollection(PipelineResponse response) : base(response) + { + } + + /// + public abstract IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default); +} diff --git a/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs b/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs index 0dedda0117104..7205c9165fdbe 100644 --- a/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs +++ b/sdk/core/System.ClientModel/src/Convenience/ClientResult.cs @@ -45,7 +45,7 @@ protected ClientResult(PipelineResponse response) /// No /// value is currently available for this /// instance. This can happen when the instance - /// is a collection type like + /// is a collection type like /// that has not yet been enumerated. public PipelineResponse GetRawResponse() { diff --git a/sdk/core/System.ClientModel/src/Convenience/ClientResultCollectionOfT.cs b/sdk/core/System.ClientModel/src/Convenience/ClientResultCollectionOfT.cs deleted file mode 100644 index e6e55122ea307..0000000000000 --- a/sdk/core/System.ClientModel/src/Convenience/ClientResultCollectionOfT.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System.ClientModel.Primitives; -using System.Collections; -using System.Collections.Generic; - -namespace System.ClientModel; - -#pragma warning disable CS1591 // public XML comments -public abstract class ClientResultCollection : ClientResult, IEnumerable -{ - // Constructor overload for collection implementations that postpone - // sending a request until GetAsyncEnumerator is called. This will typically - // be used by collections returned from client convenience methods. - protected internal ClientResultCollection() : base() - { - } - - // Constructor overload for collection implementations where the service - // has returned a response. This will typically be used by collections - // created from the return result of a client's protocol method. - protected internal ClientResultCollection(PipelineResponse response) : base(response) - { - } - - public abstract IEnumerator GetEnumerator(); - - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); -} -#pragma warning restore CS1591 // public XML comments diff --git a/sdk/core/System.ClientModel/src/Convenience/ResultCollectionOfT.cs b/sdk/core/System.ClientModel/src/Convenience/ResultCollectionOfT.cs new file mode 100644 index 0000000000000..5943cc8438f95 --- /dev/null +++ b/sdk/core/System.ClientModel/src/Convenience/ResultCollectionOfT.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ClientModel.Primitives; +using System.Collections; +using System.Collections.Generic; + +namespace System.ClientModel; + +/// +/// Represents a collection of results returned from a cloud service operation. +/// +public abstract class ResultCollection : ClientResult, IEnumerable +{ + /// + /// Create a new instance of . + /// + /// If no is provided when the + /// instance is created, it is expected that + /// a derived type will call + /// prior to a user calling . + /// This constructor is indended for use by collection implementations that + /// postpone sending a request until + /// is called. Such implementations will typically be returned from client + /// convenience methods so that callers of the methods don't need to + /// dispose the return value. + protected internal ResultCollection() : base() + { + } + + /// + /// Create a new instance of . + /// + /// The holding the + /// items in the collection, or the first set of the items in the collection. + /// + protected internal ResultCollection(PipelineResponse response) : base(response) + { + } + + /// + public abstract IEnumerator GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ClientResultCollectionTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ClientResultCollectionTests.cs index 70808b56e4a95..16ef1d4546b3a 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ClientResultCollectionTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ClientResultCollectionTests.cs @@ -71,7 +71,7 @@ public async Task DataCollectionDoesNotDisposeStream() public async Task EnumeratesModelValues() { MockSseClient client = new(); - AsyncClientResultCollection models = client.GetModelsStreamingAsync(_mockContent); + AsyncResultCollection models = client.GetModelsStreamingAsync(_mockContent); int i = 0; await foreach (MockJsonModel model in models) @@ -89,7 +89,7 @@ public async Task EnumeratesModelValues() public async Task ModelCollectionDelaysSendingRequest() { MockSseClient client = new(); - AsyncClientResultCollection models = client.GetModelsStreamingAsync(_mockContent); + AsyncResultCollection models = client.GetModelsStreamingAsync(_mockContent); Assert.IsFalse(client.ProtocolMethodCalled); @@ -110,7 +110,7 @@ public async Task ModelCollectionDelaysSendingRequest() public void ModelCollectionThrowsIfCancelled() { MockSseClient client = new(); - AsyncClientResultCollection models = client.GetModelsStreamingAsync(_mockContent); + AsyncResultCollection models = client.GetModelsStreamingAsync(_mockContent); // Set it to `cancelled: true` to validate functionality. CancellationToken token = new(true); @@ -127,7 +127,7 @@ public void ModelCollectionThrowsIfCancelled() public async Task ModelCollectionDisposesStream() { MockSseClient client = new(); - AsyncClientResultCollection models = client.GetModelsStreamingAsync(_mockContent); + AsyncResultCollection models = client.GetModelsStreamingAsync(_mockContent); await foreach (MockJsonModel model in models) { @@ -141,7 +141,7 @@ public async Task ModelCollectionDisposesStream() public void ModelCollectionGetRawResponseThrowsBeforeEnumerated() { MockSseClient client = new(); - AsyncClientResultCollection models = client.GetModelsStreamingAsync(_mockContent); + AsyncResultCollection models = client.GetModelsStreamingAsync(_mockContent); Assert.Throws(() => { PipelineResponse response = models.GetRawResponse(); }); } @@ -149,7 +149,7 @@ public void ModelCollectionGetRawResponseThrowsBeforeEnumerated() public async Task StopsOnStringBasedTerminalEvent() { MockSseClient client = new(); - AsyncClientResultCollection models = client.GetModelsStreamingAsync("[DONE]"); + AsyncResultCollection models = client.GetModelsStreamingAsync("[DONE]"); bool empty = true; await foreach (MockJsonModel model in models) diff --git a/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClient.cs b/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClient.cs index a0cdcf442e2ac..9461b0b52f18b 100644 --- a/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClient.cs +++ b/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClient.cs @@ -24,7 +24,7 @@ public class MockSseClient public bool ProtocolMethodCalled { get; private set; } // mock convenience method - public virtual AsyncClientResultCollection GetModelsStreamingAsync(string content) + public virtual AsyncResultCollection GetModelsStreamingAsync(string content) { return new AsyncMockJsonModelCollection(content, GetModelsStreamingAsync); } @@ -46,7 +46,7 @@ public virtual ClientResult GetModelsStreamingAsync(string content, RequestOptio // Internal client implementation of convenience-layer AsyncResultCollection. // This currently layers over an internal AsyncResultCollection // representing the event.data values, but does not strictly have to. - private class AsyncMockJsonModelCollection : AsyncClientResultCollection + private class AsyncMockJsonModelCollection : AsyncResultCollection { private readonly string _content; private readonly Func _protocolMethod; diff --git a/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClientExtensions.cs b/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClientExtensions.cs index 2fcad366c2662..00ebadbe6e60f 100644 --- a/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClientExtensions.cs +++ b/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClientExtensions.cs @@ -15,7 +15,7 @@ namespace ClientModel.Tests.Internal.Mocks; public static class MockSseClientExtensions { - public static AsyncClientResultCollection EnumerateDataEvents(this PipelineResponse response) + public static AsyncResultCollection EnumerateDataEvents(this PipelineResponse response) { if (response.ContentStream is null) { @@ -25,7 +25,7 @@ public static AsyncClientResultCollection EnumerateDataEvents(this P return new AsyncSseDataEventCollection(response, "[DONE]"); } - private class AsyncSseDataEventCollection : AsyncClientResultCollection + private class AsyncSseDataEventCollection : AsyncResultCollection { private readonly string _terminalData; From caebaea0b8fbd9bf895c8009d7ecc69913439ff6 Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Fri, 10 May 2024 11:44:10 -0700 Subject: [PATCH 42/45] comments --- .../src/Internal/SSE/AsyncServerSentEventEnumerable.cs | 3 +++ .../System.ClientModel/src/Internal/SSE/ServerSentEvent.cs | 5 ++++- .../src/Internal/SSE/ServerSentEventEnumerable.cs | 3 +++ .../src/Internal/SSE/ServerSentEventField.cs | 7 +++++-- .../src/Internal/SSE/ServerSentEventFieldKind.cs | 5 ++++- .../src/Internal/SSE/ServerSentEventReader.cs | 6 +++++- 6 files changed, 24 insertions(+), 5 deletions(-) diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerable.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerable.cs index 5ce063e8fcf7b..2873f95682359 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerable.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerable.cs @@ -8,6 +8,9 @@ namespace System.ClientModel.Internal; +/// +/// Represents a collection of SSE events that can be enumerated as a C# async stream. +/// internal class AsyncServerSentEventEnumerable : IAsyncEnumerable { private readonly Stream _contentStream; diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs index 333fe3ee2833c..64d5a7197afac 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs @@ -3,7 +3,10 @@ namespace System.ClientModel.Internal; -// SSE specification: https://html.spec.whatwg.org/multipage/server-sent-events.html +/// +/// Represents an SSE event. +/// See SSE specification: https://html.spec.whatwg.org/multipage/server-sent-events.html +/// internal readonly struct ServerSentEvent { // Gets the value of the SSE "event type" buffer, used to distinguish between event kinds. diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventEnumerable.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventEnumerable.cs index ab113508846d7..7d9dc995cc25b 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventEnumerable.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventEnumerable.cs @@ -7,6 +7,9 @@ namespace System.ClientModel.Internal; +/// +/// Represents a collection of SSE events that can be enumerated as a C# collection. +/// internal class ServerSentEventEnumerable : IEnumerable { private readonly Stream _contentStream; diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventField.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventField.cs index ff763a527a6b7..eaf72fb5121a4 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventField.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventField.cs @@ -3,7 +3,10 @@ namespace System.ClientModel.Internal; -// SSE specification: https://html.spec.whatwg.org/multipage/server-sent-events.html +/// +/// Represents a field that can be composed into an SSE event. +/// See SSE specification: https://html.spec.whatwg.org/multipage/server-sent-events.html +/// internal readonly struct ServerSentEventField { private static readonly ReadOnlyMemory s_eventFieldName = "event".AsMemory(); @@ -13,7 +16,7 @@ internal readonly struct ServerSentEventField public ServerSentEventFieldKind FieldType { get; } - // Note: don't expose UTF16 publicly + // Note: we don't plan to expose UTF16 publicly public ReadOnlyMemory Value { get; } internal ServerSentEventField(string line) diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventFieldKind.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventFieldKind.cs index d0208bc21ead7..3ddc00aff270c 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventFieldKind.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventFieldKind.cs @@ -3,7 +3,10 @@ namespace System.ClientModel.Internal; -// SSE specification: https://html.spec.whatwg.org/multipage/server-sent-events.html +/// +/// The kind of line or field received over an SSE stream. +/// See SSE specification: https://html.spec.whatwg.org/multipage/server-sent-events.html +/// internal enum ServerSentEventFieldKind { Ignore, diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs index 76b0273da447d..ce19a38b56ae2 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs @@ -9,7 +9,11 @@ namespace System.ClientModel.Internal; -// SSE specification: https://html.spec.whatwg.org/multipage/server-sent-events.html +/// +/// An SSE event reader that reads lines from an SSE stream and composes them +/// into SSE events. +/// See SSE specification: https://html.spec.whatwg.org/multipage/server-sent-events.html +/// internal sealed class ServerSentEventReader { private readonly Stream? _stream; From 727185ce42fa2813e18fd1b5975701d90b339b75 Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Tue, 14 May 2024 08:55:29 -0700 Subject: [PATCH 43/45] pr fb --- .../src/Internal/SSE/ServerSentEventReader.cs | 19 +-- .../AsyncServerSentEventEnumerableTests.cs | 27 +---- .../SSE/ClientResultCollectionTests.cs | 114 ++++++++---------- .../SSE/ServerSentEventEnumerableTests.cs | 25 +--- .../SSE/ServerSentEventReaderTests.cs | 27 +---- .../TestFramework/Mocks/MockSseClient.cs | 21 +++- 6 files changed, 79 insertions(+), 154 deletions(-) diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs index ce19a38b56ae2..793006da1618b 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs @@ -16,15 +16,12 @@ namespace System.ClientModel.Internal; /// internal sealed class ServerSentEventReader { - private readonly Stream? _stream; - private readonly StreamReader? _reader; + private readonly StreamReader _reader; public ServerSentEventReader(Stream stream) { - // Creator of the reader has responsibility for disposing the stream - // passed to the reader constructor. - - _stream = stream; + // The creator of the reader has responsibility for disposing the + // stream passed to the reader's constructor. _reader = new StreamReader(stream); } @@ -38,11 +35,6 @@ public ServerSentEventReader(Stream stream) /// public ServerSentEvent? TryGetNextEvent(CancellationToken cancellationToken = default) { - if (_reader is null) - { - throw new ObjectDisposedException(nameof(ServerSentEventReader)); - } - PendingEvent pending = default; while (true) { @@ -77,11 +69,6 @@ public ServerSentEventReader(Stream stream) /// public async Task TryGetNextEventAsync(CancellationToken cancellationToken = default) { - if (_reader is null) - { - throw new ObjectDisposedException(nameof(ServerSentEventReader)); - } - PendingEvent pending = default; while (true) { diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumerableTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumerableTests.cs index 385e2fc58c846..fe511057338c4 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumerableTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/AsyncServerSentEventEnumerableTests.cs @@ -6,6 +6,7 @@ using System.IO; using System.Threading; using System.Threading.Tasks; +using ClientModel.Tests.Internal.Mocks; using NUnit.Framework; namespace System.ClientModel.Tests.Convenience; @@ -15,7 +16,7 @@ public class AsyncServerSentEventEnumerableTests [Test] public async Task EnumeratesEvents() { - using Stream contentStream = BinaryData.FromString(_mockContent).ToStream(); + using Stream contentStream = BinaryData.FromString(MockSseClient.DefaultMockContent).ToStream(); AsyncServerSentEventEnumerable enumerable = new(contentStream); List events = new(); @@ -30,7 +31,7 @@ public async Task EnumeratesEvents() for (int i = 0; i < 3; i++) { Assert.AreEqual($"event.{i}", events[i].EventType); - Assert.AreEqual($"{{ \"id\": \"{i}\", \"object\": {i} }}", events[i].Data); + Assert.AreEqual($"{{ \"IntValue\": {i}, \"StringValue\": \"{i}\" }}", events[i].Data); } } @@ -39,30 +40,10 @@ public void ThrowsIfCancelled() { CancellationToken token = new(true); - using Stream contentStream = BinaryData.FromString(_mockContent).ToStream(); + using Stream contentStream = BinaryData.FromString(MockSseClient.DefaultMockContent).ToStream(); AsyncServerSentEventEnumerable enumerable = new(contentStream); IAsyncEnumerator enumerator = enumerable.GetAsyncEnumerator(token); Assert.ThrowsAsync(async () => await enumerator.MoveNextAsync()); } - - #region Helpers - - private readonly string _mockContent = """ - event: event.0 - data: { "id": "0", "object": 0 } - - event: event.1 - data: { "id": "1", "object": 1 } - - event: event.2 - data: { "id": "2", "object": 2 } - - event: done - data: [DONE] - - - """; - - #endregion } diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ClientResultCollectionTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ClientResultCollectionTests.cs index 16ef1d4546b3a..8e2fb8d3095e3 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ClientResultCollectionTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ClientResultCollectionTests.cs @@ -17,61 +17,11 @@ public ClientResultCollectionTests(bool isAsync) : base(isAsync) { } - [Test] - public async Task EnumeratesDataValues() - { - MockSseClient client = new(); - ClientResult result = client.GetModelsStreamingAsync(_mockContent, new RequestOptions()); - - int i = 0; - await foreach (BinaryData data in result.GetRawResponse().EnumerateDataEvents()) - { - MockJsonModel model = data.ToObjectFromJson(); - - Assert.AreEqual(i, model.IntValue); - Assert.AreEqual(i.ToString(), model.StringValue); - - i++; - } - - Assert.AreEqual(3, i); - } - - [Test] - public void DataCollectionThrowsIfCancelled() - { - MockSseClient client = new(); - ClientResult result = client.GetModelsStreamingAsync(_mockContent, new RequestOptions()); - - // Set it to `cancelled: true` to validate functionality. - CancellationToken token = new(true); - - Assert.ThrowsAsync(async () => - { - await foreach (BinaryData data in result.GetRawResponse().EnumerateDataEvents().WithCancellation(token)) - { - } - }); - } - - [Test] - public async Task DataCollectionDoesNotDisposeStream() - { - MockSseClient client = new(); - ClientResult result = client.GetModelsStreamingAsync(_mockContent, new RequestOptions()); - - await foreach (BinaryData data in result.GetRawResponse().EnumerateDataEvents()) - { - } - - Assert.DoesNotThrow(() => { var p = result.GetRawResponse().ContentStream!.Position; }); - } - [Test] public async Task EnumeratesModelValues() { MockSseClient client = new(); - AsyncResultCollection models = client.GetModelsStreamingAsync(_mockContent); + AsyncResultCollection models = client.GetModelsStreamingAsync(); int i = 0; await foreach (MockJsonModel model in models) @@ -89,7 +39,7 @@ public async Task EnumeratesModelValues() public async Task ModelCollectionDelaysSendingRequest() { MockSseClient client = new(); - AsyncResultCollection models = client.GetModelsStreamingAsync(_mockContent); + AsyncResultCollection models = client.GetModelsStreamingAsync(); Assert.IsFalse(client.ProtocolMethodCalled); @@ -110,7 +60,7 @@ public async Task ModelCollectionDelaysSendingRequest() public void ModelCollectionThrowsIfCancelled() { MockSseClient client = new(); - AsyncResultCollection models = client.GetModelsStreamingAsync(_mockContent); + AsyncResultCollection models = client.GetModelsStreamingAsync(); // Set it to `cancelled: true` to validate functionality. CancellationToken token = new(true); @@ -127,7 +77,7 @@ public void ModelCollectionThrowsIfCancelled() public async Task ModelCollectionDisposesStream() { MockSseClient client = new(); - AsyncResultCollection models = client.GetModelsStreamingAsync(_mockContent); + AsyncResultCollection models = client.GetModelsStreamingAsync(); await foreach (MockJsonModel model in models) { @@ -141,7 +91,7 @@ public async Task ModelCollectionDisposesStream() public void ModelCollectionGetRawResponseThrowsBeforeEnumerated() { MockSseClient client = new(); - AsyncResultCollection models = client.GetModelsStreamingAsync(_mockContent); + AsyncResultCollection models = client.GetModelsStreamingAsync(); Assert.Throws(() => { PipelineResponse response = models.GetRawResponse(); }); } @@ -162,23 +112,53 @@ public async Task StopsOnStringBasedTerminalEvent() Assert.IsTrue(empty); } - #region Helpers + [Test] + public async Task EnumeratesDataValues() + { + MockSseClient client = new(); + ClientResult result = client.GetModelsStreamingAsync(MockSseClient.DefaultMockContent, new RequestOptions()); - private readonly string _mockContent = """ - event: event.0 - data: { "IntValue": 0, "StringValue": "0" } + int i = 0; + await foreach (BinaryData data in result.GetRawResponse().EnumerateDataEvents()) + { + MockJsonModel model = data.ToObjectFromJson(); - event: event.1 - data: { "IntValue": 1, "StringValue": "1" } + Assert.AreEqual(i, model.IntValue); + Assert.AreEqual(i.ToString(), model.StringValue); - event: event.2 - data: { "IntValue": 2, "StringValue": "2" } + i++; + } - event: done - data: [DONE] + Assert.AreEqual(3, i); + } + [Test] + public void DataCollectionThrowsIfCancelled() + { + MockSseClient client = new(); + ClientResult result = client.GetModelsStreamingAsync(MockSseClient.DefaultMockContent, new RequestOptions()); - """; + // Set it to `cancelled: true` to validate functionality. + CancellationToken token = new(true); - #endregion + Assert.ThrowsAsync(async () => + { + await foreach (BinaryData data in result.GetRawResponse().EnumerateDataEvents().WithCancellation(token)) + { + } + }); + } + + [Test] + public async Task DataCollectionDoesNotDisposeStream() + { + MockSseClient client = new(); + ClientResult result = client.GetModelsStreamingAsync(MockSseClient.DefaultMockContent, new RequestOptions()); + + await foreach (BinaryData data in result.GetRawResponse().EnumerateDataEvents()) + { + } + + Assert.DoesNotThrow(() => { var p = result.GetRawResponse().ContentStream!.Position; }); + } } diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventEnumerableTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventEnumerableTests.cs index 0ec39af761e34..d32835fa7486e 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventEnumerableTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventEnumerableTests.cs @@ -4,6 +4,7 @@ using System.ClientModel.Internal; using System.Collections.Generic; using System.IO; +using ClientModel.Tests.Internal.Mocks; using NUnit.Framework; namespace System.ClientModel.Tests.Convenience; @@ -13,7 +14,7 @@ public class ServerSentEventEnumerableTests [Test] public void EnumeratesEvents() { - using Stream contentStream = BinaryData.FromString(_mockContent).ToStream(); + using Stream contentStream = BinaryData.FromString(MockSseClient.DefaultMockContent).ToStream(); ServerSentEventEnumerable enumerable = new(contentStream); List events = new(); @@ -28,27 +29,7 @@ public void EnumeratesEvents() for (int i = 0; i < 3; i++) { Assert.AreEqual($"event.{i}", events[i].EventType); - Assert.AreEqual($"{{ \"id\": \"{i}\", \"object\": {i} }}", events[i].Data); + Assert.AreEqual($"{{ \"IntValue\": {i}, \"StringValue\": \"{i}\" }}", events[i].Data); } } - - #region Helpers - - private readonly string _mockContent = """ - event: event.0 - data: { "id": "0", "object": 0 } - - event: event.1 - data: { "id": "1", "object": 1 } - - event: event.2 - data: { "id": "2", "object": 2 } - - event: done - data: [DONE] - - - """; - - #endregion } diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs index 07aac8f2aff2f..787bc875f4e34 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs @@ -21,7 +21,7 @@ public ServerSentEventReaderTests(bool isAsync) : base(isAsync) [Test] public async Task GetsEventsFromStream() { - Stream contentStream = BinaryData.FromString(_mockContent).ToStream(); + Stream contentStream = BinaryData.FromString(MockSseClient.DefaultMockContent).ToStream(); ServerSentEventReader reader = new(contentStream); List events = new(); @@ -38,7 +38,7 @@ public async Task GetsEventsFromStream() { ServerSentEvent sse = events[i]; Assert.AreEqual($"event.{i}", sse.EventType); - Assert.AreEqual($"{{ \"id\": \"{i}\", \"object\": {i} }}", sse.Data); + Assert.AreEqual($"{{ \"IntValue\": {i}, \"StringValue\": \"{i}\" }}", sse.Data); } Assert.AreEqual("done", events[3].EventType); @@ -233,31 +233,10 @@ public void ThrowsIfCancelled() { CancellationToken token = new(true); - using Stream contentStream = BinaryData.FromString(_mockContent).ToStream(); + using Stream contentStream = BinaryData.FromString(MockSseClient.DefaultMockContent).ToStream(); ServerSentEventReader reader = new(contentStream); Assert.ThrowsAsync(async () => await reader.TryGetNextEventAsync(token)); } - - #region Helpers - - // Note: raw string literal quirk removes \n from final line. - private readonly string _mockContent = """ - event: event.0 - data: { "id": "0", "object": 0 } - - event: event.1 - data: { "id": "1", "object": 1 } - - event: event.2 - data: { "id": "2", "object": 2 } - - event: done - data: [DONE] - - - """; - - #endregion } diff --git a/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClient.cs b/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClient.cs index 9461b0b52f18b..2113d2acb0474 100644 --- a/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClient.cs +++ b/sdk/core/System.ClientModel/tests/internal/TestFramework/Mocks/MockSseClient.cs @@ -21,10 +21,27 @@ namespace ClientModel.Tests.Internal.Mocks; // will no longer be needed. public class MockSseClient { + // Note: raw string literal removes \n from final line. + internal const string DefaultMockContent = """ + event: event.0 + data: { "IntValue": 0, "StringValue": "0" } + + event: event.1 + data: { "IntValue": 1, "StringValue": "1" } + + event: event.2 + data: { "IntValue": 2, "StringValue": "2" } + + event: done + data: [DONE] + + + """; + public bool ProtocolMethodCalled { get; private set; } // mock convenience method - public virtual AsyncResultCollection GetModelsStreamingAsync(string content) + public virtual AsyncResultCollection GetModelsStreamingAsync(string content = DefaultMockContent) { return new AsyncMockJsonModelCollection(content, GetModelsStreamingAsync); } @@ -114,7 +131,7 @@ async ValueTask IAsyncEnumerator.MoveNextAsync() } BinaryData data = BinaryData.FromString(_events.Current.Data); - MockJsonModel? model = ModelReaderWriter.Read(data) ?? + MockJsonModel model = ModelReaderWriter.Read(data) ?? throw new JsonException($"Failed to deserialize expected type MockJsonModel from sse data payload '{_events.Current.Data}'."); _current = model; From df2df9e8ed3aee87da767938704c12d20adc4400 Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Tue, 14 May 2024 10:04:36 -0700 Subject: [PATCH 44/45] rework last event id and retry per BCL design shift --- .../SSE/AsyncServerSentEventEnumerable.cs | 21 +++++- .../src/Internal/SSE/ServerSentEvent.cs | 17 ++--- .../Internal/SSE/ServerSentEventEnumerable.cs | 18 ++++- .../src/Internal/SSE/ServerSentEventReader.cs | 28 ++++---- .../SSE/ServerSentEventReaderTests.cs | 66 ++++++++++++++++--- .../Convenience/SSE/ServerSentEventTests.cs | 22 ------- 6 files changed, 112 insertions(+), 60 deletions(-) delete mode 100644 sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventTests.cs diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerable.cs b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerable.cs index 2873f95682359..e7efca1805b1d 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerable.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/AsyncServerSentEventEnumerable.cs @@ -17,30 +17,45 @@ internal class AsyncServerSentEventEnumerable : IAsyncEnumerable GetAsyncEnumerator(CancellationToken cancellationToken = default) { - return new AsyncServerSentEventEnumerator(_contentStream, cancellationToken); + return new AsyncServerSentEventEnumerator(_contentStream, this, cancellationToken); } private sealed class AsyncServerSentEventEnumerator : IAsyncEnumerator { - private readonly CancellationToken _cancellationToken; private readonly ServerSentEventReader _reader; + private readonly AsyncServerSentEventEnumerable _enumerable; + private readonly CancellationToken _cancellationToken; public ServerSentEvent Current { get; private set; } - public AsyncServerSentEventEnumerator(Stream contentStream, CancellationToken cancellationToken = default) + public AsyncServerSentEventEnumerator(Stream contentStream, + AsyncServerSentEventEnumerable enumerable, + CancellationToken cancellationToken = default) { _reader = new(contentStream); + _enumerable = enumerable; _cancellationToken = cancellationToken; } public async ValueTask MoveNextAsync() { ServerSentEvent? nextEvent = await _reader.TryGetNextEventAsync(_cancellationToken).ConfigureAwait(false); + _enumerable.LastEventId = _reader.LastEventId; + _enumerable.ReconnectionInterval = _reader.ReconnectionInterval; if (nextEvent.HasValue) { diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs index 64d5a7197afac..f962dd2bac4b8 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEvent.cs @@ -9,24 +9,17 @@ namespace System.ClientModel.Internal; /// internal readonly struct ServerSentEvent { - // Gets the value of the SSE "event type" buffer, used to distinguish between event kinds. + // Gets the value of the SSE "event type" buffer, used to distinguish + // between event kinds. public string EventType { get; } - // Gets the value of the SSE "data" buffer, which holds the payload of the server-sent event. + // Gets the value of the SSE "data" buffer, which holds the payload of the + // server-sent event. public string Data { get; } - // Gets the value of the "last event ID" buffer, with which a user agent can reestablish a session. - public string? Id { get; } - - // If present, gets the defined "retry" value for the event, which represents the delay before reconnecting. - public TimeSpan? ReconnectionTime { get; } - - public ServerSentEvent(string type, string data, string? id, string? retry) + public ServerSentEvent(string type, string data) { EventType = type; Data = data; - Id = id; - ReconnectionTime = retry is null ? null : - int.TryParse(retry, out int time) ? TimeSpan.FromMilliseconds(time) : null; } } diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventEnumerable.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventEnumerable.cs index 7d9dc995cc25b..8c0ebca656813 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventEnumerable.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventEnumerable.cs @@ -4,6 +4,7 @@ using System.Collections; using System.Collections.Generic; using System.IO; +using System.Threading; namespace System.ClientModel.Internal; @@ -16,12 +17,21 @@ internal class ServerSentEventEnumerable : IEnumerable public ServerSentEventEnumerable(Stream contentStream) { + Argument.AssertNotNull(contentStream, nameof(contentStream)); + _contentStream = contentStream; + + LastEventId = string.Empty; + ReconnectionInterval = Timeout.InfiniteTimeSpan; } + public string LastEventId { get; private set; } + + public TimeSpan ReconnectionInterval { get; private set; } + public IEnumerator GetEnumerator() { - return new ServerSentEventEnumerator(_contentStream); + return new ServerSentEventEnumerator(_contentStream, this); } IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); @@ -29,10 +39,12 @@ public IEnumerator GetEnumerator() private sealed class ServerSentEventEnumerator : IEnumerator { private readonly ServerSentEventReader _reader; + private readonly ServerSentEventEnumerable _enumerable; - public ServerSentEventEnumerator(Stream contentStream) + public ServerSentEventEnumerator(Stream contentStream, ServerSentEventEnumerable enumerable) { _reader = new(contentStream); + _enumerable = enumerable; } public ServerSentEvent Current { get; private set; } @@ -42,6 +54,8 @@ public ServerSentEventEnumerator(Stream contentStream) public bool MoveNext() { ServerSentEvent? nextEvent = _reader.TryGetNextEvent(); + _enumerable.LastEventId = _reader.LastEventId; + _enumerable.ReconnectionInterval= _reader.ReconnectionInterval; if (nextEvent.HasValue) { diff --git a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs index 793006da1618b..e1e881743533c 100644 --- a/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs +++ b/sdk/core/System.ClientModel/src/Internal/SSE/ServerSentEventReader.cs @@ -20,11 +20,20 @@ internal sealed class ServerSentEventReader public ServerSentEventReader(Stream stream) { + Argument.AssertNotNull(stream, nameof(stream)); + // The creator of the reader has responsibility for disposing the // stream passed to the reader's constructor. _reader = new StreamReader(stream); + + LastEventId = string.Empty; + ReconnectionInterval = Timeout.InfiniteTimeSpan; } + public string LastEventId { get; private set; } + + public TimeSpan ReconnectionInterval { get; private set; } + /// /// Synchronously retrieves the next server-sent event from the underlying stream, blocking until a new event is /// available and returning null once no further data is present on the stream. @@ -93,7 +102,7 @@ public ServerSentEventReader(Stream stream) } } - private static void ProcessLine(string line, ref PendingEvent pending, out bool dispatch) + private void ProcessLine(string line, ref PendingEvent pending, out bool dispatch) { dispatch = false; @@ -126,10 +135,13 @@ private static void ProcessLine(string line, ref PendingEvent pending, out bool pending.DataFields.Add(field); break; case ServerSentEventFieldKind.Id: - pending.IdField = field; + LastEventId = field.Value.ToString(); break; case ServerSentEventFieldKind.Retry: - pending.RetryField = field; + if (field.Value.Length > 0 && int.TryParse(field.Value.ToString(), out int retry)) + { + ReconnectionInterval = TimeSpan.FromMilliseconds(retry); + } break; default: // Ignore @@ -147,8 +159,6 @@ private struct PendingEvent public int DataLength { get; set; } public List DataFields => _dataFields ??= new(); public ServerSentEventField? EventTypeField { get; set; } - public ServerSentEventField? IdField { get; set; } - public ServerSentEventField? RetryField { get; set; } public ServerSentEvent ToEvent() { @@ -159,12 +169,6 @@ public ServerSentEvent ToEvent() EventTypeField.Value.Value.ToString() : "message"; - string? id = IdField.HasValue && IdField.Value.Value.Length > 0 ? - IdField.Value.Value.ToString() : default; - - string? retry = RetryField.HasValue && RetryField.Value.Value.Length > 0 ? - RetryField.Value.Value.ToString() : default; - Memory buffer = new(new char[DataLength]); int curr = 0; @@ -182,7 +186,7 @@ public ServerSentEvent ToEvent() // Per spec, remove trailing LF from concatenated data fields. string data = buffer.Slice(0, buffer.Length - 1).ToString(); - return new ServerSentEvent(type, data, id, retry); + return new ServerSentEvent(type, data); } } } diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs index 787bc875f4e34..67365117e576e 100644 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs +++ b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventReaderTests.cs @@ -89,10 +89,12 @@ public async Task HandlesDoneEvent() ServerSentEvent? sse = await reader.TryGetNextEventSyncOrAsync(IsAsync); Assert.IsNotNull(sse); + Assert.AreEqual("stop", sse.Value.EventType); Assert.AreEqual("~stop~", sse.Value.Data); - Assert.IsNull(sse.Value.Id); - Assert.IsNull(sse.Value.ReconnectionTime); + + Assert.AreEqual(string.Empty, reader.LastEventId); + Assert.AreEqual(Timeout.InfiniteTimeSpan, reader.ReconnectionInterval); } [Test] @@ -110,9 +112,11 @@ public async Task ConcatenatesDataLines() ServerSentEvent? sse = await reader.TryGetNextEventSyncOrAsync(IsAsync); Assert.IsNotNull(sse); + Assert.AreEqual("YHOO\n+2\n10", sse.Value.Data); - Assert.IsNull(sse.Value.Id); - Assert.IsNull(sse.Value.ReconnectionTime); + + Assert.AreEqual(string.Empty, reader.LastEventId); + Assert.AreEqual(Timeout.InfiniteTimeSpan, reader.ReconnectionInterval); } [Test] @@ -128,10 +132,9 @@ public async Task DefaultsEventTypeToMessage() ServerSentEvent? sse = await reader.TryGetNextEventSyncOrAsync(IsAsync); Assert.IsNotNull(sse); + Assert.AreEqual("message", sse.Value.EventType); Assert.AreEqual("data", sse.Value.Data); - Assert.IsNull(sse.Value.Id); - Assert.IsNull(sse.Value.ReconnectionTime); } [Test] @@ -154,24 +157,27 @@ public async Task SecondTestCaseFromSpec() ServerSentEventReader reader = new(contentStream); List events = new(); + List ids = new(); ServerSentEvent? sse = await reader.TryGetNextEventSyncOrAsync(IsAsync); while (sse is not null) { events.Add(sse.Value); + ids.Add(reader.LastEventId.ToString()); + sse = await reader.TryGetNextEventSyncOrAsync(IsAsync); } Assert.AreEqual(3, events.Count); Assert.AreEqual("first event", events[0].Data); - Assert.AreEqual("1", events[0].Id); + Assert.AreEqual("1", ids[0]); Assert.AreEqual("second event", events[1].Data); - Assert.IsNull(events[1].Id); + Assert.AreEqual(string.Empty, ids[1]); Assert.AreEqual(" third event", events[2].Data); - Assert.IsNull(events[2].Id); + Assert.AreEqual(string.Empty, ids[2]); } [Test] @@ -228,6 +234,48 @@ public async Task FourthSpecTestCase() Assert.AreEqual(events[0].Data, events[1].Data); } + [Test] + public async Task SetsReconnectionInterval() + { + Stream contentStream = BinaryData.FromString(""" + data: test + + data: test + retry: 2500 + + data: test + retry: + + + """).ToStream(); + ServerSentEventReader reader = new(contentStream); + + List events = new(); + List retryValues = new(); + + ServerSentEvent? sse = await reader.TryGetNextEventSyncOrAsync(IsAsync); + while (sse is not null) + { + events.Add(sse.Value); + retryValues.Add(reader.ReconnectionInterval); + + sse = await reader.TryGetNextEventSyncOrAsync(IsAsync); + } + + Assert.AreEqual(3, events.Count); + + // Defaults to infinite timespan + Assert.AreEqual("test", events[0].Data); + Assert.AreEqual(Timeout.InfiniteTimeSpan, retryValues[0]); + + Assert.AreEqual("test", events[1].Data); + Assert.AreEqual(new TimeSpan(0, 0, 0, 2, 500), retryValues[1]); + + // Ignores invalid values + Assert.AreEqual("test", events[2].Data); + Assert.AreEqual(new TimeSpan(0, 0, 0, 2, 500), retryValues[2]); + } + [Test] public void ThrowsIfCancelled() { diff --git a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventTests.cs b/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventTests.cs deleted file mode 100644 index 2c4e13f2695d6..0000000000000 --- a/sdk/core/System.ClientModel/tests/internal/Convenience/SSE/ServerSentEventTests.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System.ClientModel.Internal; -using NUnit.Framework; - -namespace System.ClientModel.Tests.Convenience; - -public class ServerSentEventTests -{ - [Test] - public void ParsesReconnectionTime() - { - string retryTimeInMillis = "2500"; - ServerSentEvent sse = new("message", "data", id: default, retryTimeInMillis); - - Assert.AreEqual("message", sse.EventType); - Assert.AreEqual("data", sse.Data); - Assert.IsNull(sse.Id); - Assert.AreEqual(new TimeSpan(0, 0, 0, 2, 500), sse.ReconnectionTime); - } -} From af84592d7681e05f83f8fde70125572b6e091a24 Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Tue, 14 May 2024 10:31:32 -0700 Subject: [PATCH 45/45] add CHANGELOG entry --- sdk/core/System.ClientModel/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sdk/core/System.ClientModel/CHANGELOG.md b/sdk/core/System.ClientModel/CHANGELOG.md index 5e464475e950b..622aa9d4d41b9 100644 --- a/sdk/core/System.ClientModel/CHANGELOG.md +++ b/sdk/core/System.ClientModel/CHANGELOG.md @@ -5,9 +5,13 @@ ### Features Added - Added `BufferResponse` property to `RequestOptions` so protocol method callers can turn off response buffering if desired. +- Added `AsyncResultCollection` and `ResultCollection` for clients to return from service methods where the service response contains a collection of values. +- Added `SetRawResponse` method to `ClientResult` to allow the response held by the result to be changed, for example by derived types that obtain multiple responses from polling the service. ### Breaking Changes +- `ClientResult.GetRawResponse` will now throw `InvalidOperationException` if called before the result's raw response is set, for example by collection result types that delay sending a request to the service until the collection is enumerated. + ### Bugs Fixed ### Other Changes