diff --git a/CHANGELOG.md b/CHANGELOG.md index c835e96..2bd5062 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the same content into release_notes.md for NuGet packaging. Do NOT put HTML comments in release_notes.md — it is packed verbatim into . --> +### Added + +- **`ConnectorTriggerPayload` helper to read trigger callbacks** — turns a raw Connector Namespace trigger callback (`string` or `Stream`) into a typed payload or decoded file bytes without per-function boilerplate. `Read` / `ReadAsync` deserialize metadata triggers (e.g. OneDrive `OnNewFilesV2`) with case-insensitive property matching, so camelCase wire fields bind correctly instead of silently yielding all-`null` items. `TryReadBinaryContent` / `ReadBinaryContentAsync` decode binary-content triggers (e.g. OneDrive `OnNewFileV2`), whose body is a base64 string. The `Stream` overloads read the caller-owned stream without closing it and enforce a generous, overridable body-size limit (`DefaultMaxBodySizeBytes`, 100 MB); `TryReadBinaryContent` returns `false` (rather than throwing) on malformed JSON. ([#190](https://github.com/Azure/Connectors-NET-SDK/issues/190)) + +### Changed + +- **`TriggerCallbackBodyConverter` now throws an actionable error for binary-content bodies** — when a binary-content trigger callback (a JSON string body, e.g. OneDrive `OnNewFileV2`) is deserialized into a metadata payload type, the error now explains the cause and points to `ConnectorTriggerPayload.TryReadBinaryContent` / `ReadBinaryContentAsync` instead of failing with a generic token-mismatch message. ([#190](https://github.com/Azure/Connectors-NET-SDK/issues/190)) + ## [0.12.0-preview.1] - 2026-06-02 ### Breaking Changes diff --git a/README.md b/README.md index e000267..aed5ffb 100644 --- a/README.md +++ b/README.md @@ -151,11 +151,21 @@ Authentication uses Azure.Core `TokenCredential` directly — any credential fro | `ConnectorJsonSerializer` | JSON serialization with connector conventions | | `JsonConverters` | Custom converters for connector types | +### Triggers + +| Component | Description | +|-----------|-------------| +| `TriggerCallbackPayload` / `TriggerCallbackBody` | Typed envelope for connector trigger callbacks; normalizes batch and single-item shapes into `Body.Value` | +| `ConnectorTriggerPayload` | Helpers to read a raw trigger callback (`string`/`Stream`) into a typed payload (`Read`/`ReadAsync`) or decoded binary file bytes (`TryReadBinaryContent`/`ReadBinaryContentAsync`), with case-insensitive matching and a bounded read | + +See [docs/triggers.md](docs/triggers.md) for the trigger architecture, payload shapes, and end-to-end usage. + ## Documentation - [docs/concepts.md](docs/concepts.md) - Key concepts, terminology, and architecture - [GENERATION.md](GENERATION.md) - How to generate connector code - [docs/connection-setup.md](docs/connection-setup.md) - Setting up connections for local testing +- [docs/triggers.md](docs/triggers.md) - Trigger architecture, typed payloads, and the `ConnectorTriggerPayload` reader - [ROADMAP.md](ROADMAP.md) - Connector generation progress and lessons learned - [Azure/Connectors-NET-Samples](https://github.com/Azure/Connectors-NET-Samples) - Full working samples (Azure Functions, triggers, etc.) diff --git a/docs/triggers.md b/docs/triggers.md index 8692ca6..1833228 100644 --- a/docs/triggers.md +++ b/docs/triggers.md @@ -107,6 +107,33 @@ Use the typed class directly to deserialize callbacks: var payload = JsonSerializer.Deserialize(callbackJson); ``` +### Reading callbacks with `ConnectorTriggerPayload` + +`ConnectorTriggerPayload` (in the `Azure.Connectors.Sdk` namespace) removes the boilerplate of reading a callback body — bounded read, case-insensitive deserialization, and binary-vs-metadata discrimination — so a trigger handler can go straight from the request body to a typed payload: + +```csharp +// Metadata triggers (e.g. OnNewFilesV2) — string or Stream overloads +var payload = await ConnectorTriggerPayload + .ReadAsync(request.Body, cancellationToken: cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + +foreach (var file in payload?.Body?.Value ?? Array.Empty()) +{ + // file.Id, file.Name, file.Path, file.Size ... +} +``` + +Property matching is **case-insensitive**, so callbacks whose wire fields are camelCase bind correctly instead of silently producing all-`null` items. The stream overloads read the caller-owned stream without closing it and enforce a generous body-size limit (`ConnectorTriggerPayload.DefaultMaxBodySizeBytes`, 100 MB, overridable per call). For **binary-content** triggers (see below), use `TryReadBinaryContent` / `ReadBinaryContentAsync`, which decode the base64 `{"body":""}` shape into file bytes: + +```csharp +// Binary-content triggers (e.g. OnNewFileV2) +byte[]? fileBytes = await ConnectorTriggerPayload + .ReadBinaryContentAsync(request.Body, cancellationToken: cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); +``` + +If a binary-content (string) body is read into a metadata payload type, deserialization throws an actionable `JsonException` that points to the binary-content helpers. + ## Trigger Operation Constants Each connector exposes a `{Connector}TriggerOperations` static class with the operation name strings: diff --git a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs new file mode 100644 index 0000000..33262b8 --- /dev/null +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs @@ -0,0 +1,454 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +using System; +using System.Buffers; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.Connectors.Sdk.Serialization; + +namespace Azure.Connectors.Sdk; + +/// +/// Helpers that turn a raw Connector Namespace trigger callback (an HTTP body +/// delivered as a or ) into a typed +/// or into the decoded file bytes of a +/// binary-content trigger. +/// +/// +/// +/// Connector Namespace delivers two distinct trigger callback shapes for file connectors: +/// +/// +/// +/// Metadata (properties only), e.g. OnNewFilesV2 +/// +/// The body is an object envelope {"body":{"value":[{...item...}]}}. +/// Read it with / +/// . +/// +/// +/// +/// Binary content, e.g. OnNewFileV2 +/// +/// The body is a base64-encoded string {"body":"<base64>"}. +/// Read it with / +/// . +/// +/// +/// +/// +/// All metadata reads use case-insensitive property matching, so payloads whose wire +/// fields are camelCase deserialize correctly instead of silently yielding all- +/// items. +/// +/// +public static class ConnectorTriggerPayload +{ + /// + /// The default maximum trigger callback body size, in bytes, enforced by the stream-based + /// readers (100 MB). This is a generous ceiling that guards against unbounded buffering of a + /// hostile or malformed stream while comfortably accommodating large binary-content callbacks. + /// Override it per call with the maxBodySizeBytes parameter. + /// + public const long DefaultMaxBodySizeBytes = 100L * 1024 * 1024; + + /// + /// The buffer size, in bytes, rented from the shared for each + /// stream read. 80 KB matches the default chunk size, + /// balancing read throughput against per-call memory. + /// + private const int ReadChunkSizeBytes = 81920; + + /// + /// Gets the used to read trigger callback payloads. + /// Property matching is case-insensitive so camelCase wire fields bind correctly. + /// + public static JsonSerializerOptions SerializerOptions => ConnectorJsonSerializer.Options; + + /// + /// Reads a metadata trigger callback (for example OneDrive OnNewFilesV2) into its + /// typed payload. The expected wire shape is {"body":{"value":[{...item...}]}}. + /// + /// + /// The connector-specific payload type, a subclass of + /// (for example OneDriveForBusinessOnNewFilesTriggerPayload). + /// + /// The raw JSON callback body. + /// The deserialized payload, or when is JSON null. + /// is . + /// + /// The body was a base64 string (a binary-content trigger such as OnNewFileV2) rather than + /// a metadata object; read it with instead. + /// + public static TPayload? Read(string json) + where TPayload : class + { + ArgumentNullException.ThrowIfNull(json); + + return JsonSerializer.Deserialize(json, ConnectorTriggerPayload.SerializerOptions); + } + + /// + /// Reads a metadata trigger callback (for example OneDrive OnNewFilesV2) from a stream into + /// its typed payload. The expected wire shape is {"body":{"value":[{...item...}]}}. + /// + /// + /// The connector-specific payload type, a subclass of + /// (for example OneDriveForBusinessOnNewFilesTriggerPayload). + /// + /// The callback body stream (for example HttpRequestData.Body). The stream is read but not disposed; the caller retains ownership. + /// + /// The maximum number of bytes to read from before failing. + /// Defaults to . + /// + /// The cancellation token. + /// The deserialized payload, or when the body is JSON null. + /// is . + /// is not greater than zero. + /// exceeded . + /// + /// The body was a base64 string (a binary-content trigger such as OnNewFileV2) rather than + /// a metadata object; read it with instead. + /// + public static async ValueTask ReadAsync( + Stream body, + long maxBodySizeBytes = ConnectorTriggerPayload.DefaultMaxBodySizeBytes, + CancellationToken cancellationToken = default) + where TPayload : class + { + ArgumentNullException.ThrowIfNull(body); + + if (maxBodySizeBytes <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(maxBodySizeBytes), + maxBodySizeBytes, + "The maximum body size must be greater than zero."); + } + + var bounded = new BoundedStream(body, maxBodySizeBytes); + return await JsonSerializer + .DeserializeAsync(bounded, ConnectorTriggerPayload.SerializerOptions, cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + } + + /// + /// Attempts to read a binary-content trigger callback (for example OneDrive OnNewFileV2), + /// whose wire shape is {"body":"<base64>"}, into the decoded file bytes. + /// + /// The raw JSON callback body. + /// + /// When this method returns , the decoded file bytes (empty when the body + /// string was empty). When it returns , an empty array. + /// + /// + /// when the callback carried a base64 string body and was decoded; + /// when was not valid JSON, the body was not a + /// JSON string (for example a metadata callback), or the string was not valid base64. + /// + /// is . + public static bool TryReadBinaryContent(string json, out byte[] content) + { + ArgumentNullException.ThrowIfNull(json); + + content = Array.Empty(); + + JsonDocument document; + try + { + document = JsonDocument.Parse(json); + } + catch (JsonException) + { + // This is a Try* API: malformed JSON is a "could not read" outcome, not an exception. + return false; + } + + using (document) + { + return ConnectorTriggerPayload.TryDecodeBinaryBody(document, out content); + } + } + + /// + /// Decodes the base64 body string of a parsed binary-content trigger callback into bytes. + /// + /// The parsed callback document. + /// + /// When this method returns , the decoded file bytes (empty when the body + /// string was empty). When it returns , an empty array. + /// + /// + /// when the document carried a base64 string body and was decoded; + /// when the root was not an object, the body was not a JSON string, or + /// the string was not valid base64. + /// + private static bool TryDecodeBinaryBody(JsonDocument document, out byte[] content) + { + content = Array.Empty(); + + // TryGetProperty throws InvalidOperationException when the root is not an object + // (for example a JSON null, array, or string). Guard the kind first so a non-object + // body is a "could not read" outcome rather than an exception, honouring the Try* contract. + if (document.RootElement.ValueKind != JsonValueKind.Object || + !document.RootElement.TryGetProperty(TriggerCallbackPropertyNames.Body, out JsonElement bodyElement) || + bodyElement.ValueKind != JsonValueKind.String) + { + return false; + } + + // The base64 string may arrive wrapped in extra quotes from the Logic Apps + // expression engine; strip them before decoding. + string base64Content = (bodyElement.GetString() ?? string.Empty).Trim('"'); + + if (base64Content.Length == 0) + { + return true; + } + + // Decode directly into a single right-sized array. Avoids the doubled peak memory + // of renting an oversized buffer and then copying into a right-sized array, which + // matters for large binary trigger bodies. The try/catch keeps the Try* contract: + // invalid base64 returns false rather than throwing. + try + { + content = Convert.FromBase64String(base64Content); + return true; + } + catch (FormatException) + { + content = Array.Empty(); + return false; + } + } + + /// + /// Reads a binary-content trigger callback (for example OneDrive OnNewFileV2) from a stream, + /// whose wire shape is {"body":"<base64>"}, into the decoded file bytes. + /// + /// The callback body stream (for example HttpRequestData.Body). The stream is read but not disposed; the caller retains ownership. + /// + /// The maximum number of bytes to read from before failing. + /// Defaults to . + /// + /// The cancellation token. + /// + /// The decoded file bytes, or when the body was not a JSON string body + /// (for example a metadata callback) or was not valid base64. + /// + /// is . + /// is not greater than zero. + /// exceeded . + public static async ValueTask ReadBinaryContentAsync( + Stream body, + long maxBodySizeBytes = ConnectorTriggerPayload.DefaultMaxBodySizeBytes, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(body); + + byte[] utf8Json = await ConnectorTriggerPayload + .ReadBoundedAsync(body, maxBodySizeBytes, cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + + // Parse straight from the UTF-8 bytes (JSON's native encoding) rather than first + // decoding to a UTF-16 string, avoiding a large intermediate allocation for big bodies. + JsonDocument document; + try + { + document = JsonDocument.Parse(utf8Json); + } + catch (JsonException) + { + return null; + } + + using (document) + { + return ConnectorTriggerPayload.TryDecodeBinaryBody(document, out byte[] content) + ? content + : null; + } + } + + /// + /// Reads the caller-owned stream into a byte array, enforcing + /// . The stream is read but never disposed. + /// + /// The stream to read. + /// The maximum number of bytes to read before failing. + /// The cancellation token. + /// The bytes read from the stream. + /// is not greater than zero. + /// exceeded . + private static async ValueTask ReadBoundedAsync( + Stream body, + long maxBodySizeBytes, + CancellationToken cancellationToken) + { + if (maxBodySizeBytes <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(maxBodySizeBytes), + maxBodySizeBytes, + "The maximum body size must be greater than zero."); + } + + using var buffer = new MemoryStream(); + byte[] chunk = ArrayPool.Shared.Rent(ConnectorTriggerPayload.ReadChunkSizeBytes); + try + { + long totalBytesRead = 0; + while (true) + { + // Never request more than one byte past the remaining allowance, so a single + // ReadAsync cannot pull far beyond maxBodySizeBytes before the limit is checked. + // The extra byte lets us detect an over-limit body without reading the whole stream. + // Compare against the chunk length WITHOUT adding the probe first, so a very large + // maxBodySizeBytes (e.g. long.MaxValue) cannot overflow the long arithmetic; only + // add the +1 probe in the branch where the remaining allowance is the smaller bound. + long remainingAllowance = maxBodySizeBytes - totalBytesRead; + int requestSize = remainingAllowance >= chunk.Length + ? chunk.Length + : (int)Math.Min(chunk.Length, remainingAllowance + 1); + + int bytesRead = await body + .ReadAsync(chunk.AsMemory(0, requestSize), cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + if (bytesRead <= 0) + { + break; + } + + totalBytesRead += bytesRead; + if (totalBytesRead > maxBodySizeBytes) + { + throw new InvalidOperationException( + $"The trigger callback body exceeded the maximum allowed size of {maxBodySizeBytes} bytes."); + } + + buffer.Write(chunk, 0, bytesRead); + } + } + finally + { + // Clear the rented buffer on return: it can hold trigger callback content + // (including base64 file bytes), and a subsequent renter in the same process + // must not be able to observe residual data. + ArrayPool.Shared.Return(chunk, clearArray: true); + } + + return buffer.ToArray(); + } + + /// + /// A read-only wrapper that enforces a byte limit on the underlying stream without + /// buffering the entire content. Each read is capped to at most the remaining + /// allowance plus one byte (to detect over-limit bodies), and + /// is thrown when the limit is exceeded. + /// The inner stream is never closed or disposed; the caller retains ownership. + /// + private sealed class BoundedStream : Stream + { + private readonly Stream _inner; + private readonly long _maxBytes; + private long _totalBytesRead; + + public BoundedStream(Stream inner, long maxBytes) + { + this._inner = inner; + this._maxBytes = maxBytes; + } + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + int cappedCount = this.CapRequestSize(count); + int bytesRead = this._inner.Read(buffer, offset, cappedCount); + this.EnforceBound(bytesRead); + return bytesRead; + } + + public override async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + { + int cappedLength = this.CapRequestSize(buffer.Length); + int bytesRead = await this._inner + .ReadAsync(buffer.Slice(0, cappedLength), cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + this.EnforceBound(bytesRead); + return bytesRead; + } + + public override async Task ReadAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken) + { + int cappedCount = this.CapRequestSize(count); + int bytesRead = await this._inner + .ReadAsync(buffer, offset, cappedCount, cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + this.EnforceBound(bytesRead); + return bytesRead; + } + + public override void Flush() + { + } + + public override long Seek(long offset, SeekOrigin origin) => + throw new NotSupportedException(); + + public override void SetLength(long value) => + throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + // Intentionally do not dispose the inner stream — the caller owns it. + } + + /// + /// Caps the requested read size so a single read never pulls far beyond the + /// remaining allowance. Mirrors the logic in . + /// + private int CapRequestSize(int requestedCount) + { + long remainingAllowance = this._maxBytes - this._totalBytesRead; + return remainingAllowance >= requestedCount + ? requestedCount + : (int)Math.Min(requestedCount, remainingAllowance + 1); + } + + private void EnforceBound(int bytesRead) + { + this._totalBytesRead += bytesRead; + if (this._totalBytesRead > this._maxBytes) + { + throw new InvalidOperationException( + $"The trigger callback body exceeded the maximum allowed size of {this._maxBytes} bytes."); + } + } + } +} diff --git a/src/Azure.Connectors.Sdk/TriggerCallbackPayload.cs b/src/Azure.Connectors.Sdk/TriggerCallbackPayload.cs index 929fa92..be5bf72 100644 --- a/src/Azure.Connectors.Sdk/TriggerCallbackPayload.cs +++ b/src/Azure.Connectors.Sdk/TriggerCallbackPayload.cs @@ -26,7 +26,7 @@ public class TriggerCallbackPayload /// /// The body envelope containing the trigger items. /// - [JsonPropertyName("body")] + [JsonPropertyName(TriggerCallbackPropertyNames.Body)] public TriggerCallbackBody? Body { get; init; } } @@ -46,7 +46,7 @@ public class TriggerCallbackBody /// "value": null property (or a null body), so consumers should null-check before /// iterating. /// - [JsonPropertyName("value")] + [JsonPropertyName(TriggerCallbackPropertyNames.Value)] public IReadOnlyList? Value { get; internal set; } } @@ -89,6 +89,19 @@ internal sealed class TriggerCallbackBodyConverter : JsonConverter was a JSON string, not an object. " + + "This is a binary-content trigger callback (for example OneDrive 'OnNewFileV2'), whose body is a base64-encoded file. " + + $"Read it with {nameof(ConnectorTriggerPayload)}.{nameof(ConnectorTriggerPayload.TryReadBinaryContent)} or " + + $"{nameof(ConnectorTriggerPayload)}.{nameof(ConnectorTriggerPayload.ReadBinaryContentAsync)} instead of deserializing to a metadata payload type."); + } + if (reader.TokenType != JsonTokenType.StartObject) { throw new JsonException($"Expected StartObject or Null for TriggerCallbackBody<{typeof(T).Name}>, got {reader.TokenType}."); @@ -119,7 +132,7 @@ internal sealed class TriggerCallbackBodyConverter : JsonConverter +/// Wire property names shared by the Connector Namespace trigger callback envelope +/// ( / ) and the +/// readers. +/// +internal static class TriggerCallbackPropertyNames +{ + /// + /// The outer envelope property carrying the trigger body ({"body": ...}). + /// + internal const string Body = "body"; + + /// + /// The inner body property carrying the batch item array ({"value": [...]}). + /// + internal const string Value = "value"; +} diff --git a/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs new file mode 100644 index 0000000..cfc0f44 --- /dev/null +++ b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTests.cs @@ -0,0 +1,360 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +using System; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.Connectors.Sdk.OneDriveForBusiness.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Connectors.Sdk.Tests +{ + /// + /// Tests for — reading metadata and binary-content + /// trigger callbacks into typed payloads and file bytes. + /// + [TestClass] + public class ConnectorTriggerPayloadTests + { + private const string MetadataPascalCasePayload = """ + {"body":{"value":[{"Id":"01ABC","Name":"report.docx","Path":"/Documents/report.docx","Size":1234,"IsFolder":false}]}} + """; + + private const string MetadataCamelCasePayload = """ + {"body":{"value":[{"id":"01ABC","name":"report.docx","path":"/Documents/report.docx","size":1234,"isFolder":false}]}} + """; + + [TestMethod] + public void Read_MetadataPascalCase_PopulatesItem() + { + // Arrange & Act + var payload = ConnectorTriggerPayload.Read( + ConnectorTriggerPayloadTests.MetadataPascalCasePayload); + + // Assert + Assert.IsNotNull(payload); + Assert.IsNotNull(payload.Body); + Assert.IsNotNull(payload.Body.Value); + Assert.AreEqual(1, payload.Body.Value.Count); + Assert.AreEqual("01ABC", payload.Body.Value[0].Id); + Assert.AreEqual("report.docx", payload.Body.Value[0].Name); + } + + [TestMethod] + public void Read_MetadataCamelCase_PopulatesItemCaseInsensitively() + { + // Arrange & Act + var payload = ConnectorTriggerPayload.Read( + ConnectorTriggerPayloadTests.MetadataCamelCasePayload); + + // Assert — camelCase wire fields must still bind (regression guard against all-null items). + Assert.IsNotNull(payload); + Assert.IsNotNull(payload.Body); + Assert.IsNotNull(payload.Body.Value); + Assert.AreEqual(1, payload.Body.Value.Count); + Assert.AreEqual("01ABC", payload.Body.Value[0].Id); + Assert.AreEqual("report.docx", payload.Body.Value[0].Name); + } + + [TestMethod] + public async Task ReadAsync_MetadataStream_PopulatesItem() + { + // Arrange + using var stream = new MemoryStream( + Encoding.UTF8.GetBytes(ConnectorTriggerPayloadTests.MetadataPascalCasePayload)); + + // Act + var payload = await ConnectorTriggerPayload + .ReadAsync(stream) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsNotNull(payload); + Assert.IsNotNull(payload.Body); + Assert.IsNotNull(payload.Body.Value); + Assert.AreEqual(1, payload.Body.Value.Count); + Assert.AreEqual("report.docx", payload.Body.Value[0].Name); + } + + [TestMethod] + public void Read_NullJson_Throws() + { + // Arrange, Act & Assert + Assert.ThrowsExactly( + () => ConnectorTriggerPayload.Read(null!)); + } + + [TestMethod] + public void Read_BinaryStringBody_ThrowsActionableJsonException() + { + // Arrange — a binary-content trigger delivers {"body":""}. + string base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("hello")); + string payload = $$"""{"body":"{{base64}}"}"""; + + // Act + var exception = Assert.ThrowsExactly( + () => ConnectorTriggerPayload.Read(payload)); + + // Assert — the message must steer the developer to the binary-content helper. + StringAssert.Contains(exception.Message, nameof(ConnectorTriggerPayload.TryReadBinaryContent)); + StringAssert.Contains(exception.Message, "binary-content trigger"); + } + + [TestMethod] + public void TryReadBinaryContent_Base64StringBody_DecodesBytes() + { + // Arrange + byte[] expected = Encoding.UTF8.GetBytes("hello from trigger test"); + string base64 = Convert.ToBase64String(expected); + string payload = $$"""{"body":"{{base64}}"}"""; + + // Act + bool result = ConnectorTriggerPayload.TryReadBinaryContent(payload, out byte[] content); + + // Assert + Assert.IsTrue(result); + CollectionAssert.AreEqual(expected, content); + } + + [TestMethod] + public void TryReadBinaryContent_EmptyStringBody_ReturnsTrueWithEmptyContent() + { + // Arrange + const string payload = """{"body":""}"""; + + // Act + bool result = ConnectorTriggerPayload.TryReadBinaryContent(payload, out byte[] content); + + // Assert + Assert.IsTrue(result); + Assert.AreEqual(0, content.Length); + } + + [TestMethod] + public void TryReadBinaryContent_MetadataObjectBody_ReturnsFalse() + { + // Arrange — a metadata callback has an object body, not a string. + // Act + bool result = ConnectorTriggerPayload.TryReadBinaryContent( + ConnectorTriggerPayloadTests.MetadataPascalCasePayload, + out byte[] content); + + // Assert + Assert.IsFalse(result); + Assert.AreEqual(0, content.Length); + } + + [TestMethod] + public void TryReadBinaryContent_NonBase64StringBody_ReturnsFalse() + { + // Arrange — a string body that is not valid base64. + const string payload = """{"body":"not valid base64 !!!"}"""; + + // Act + bool result = ConnectorTriggerPayload.TryReadBinaryContent(payload, out byte[] content); + + // Assert + Assert.IsFalse(result); + Assert.AreEqual(0, content.Length); + } + + [TestMethod] + public void TryReadBinaryContent_MalformedJson_ReturnsFalse() + { + // Arrange — a Try* API must not throw on invalid JSON. + const string payload = "this is not json {"; + + // Act + bool result = ConnectorTriggerPayload.TryReadBinaryContent(payload, out byte[] content); + + // Assert + Assert.IsFalse(result); + Assert.AreEqual(0, content.Length); + } + + [TestMethod] + [DataRow("null")] + [DataRow("[]")] + [DataRow("\"a string root\"")] + [DataRow("42")] + public void TryReadBinaryContent_NonObjectRoot_ReturnsFalse(string payload) + { + // Arrange — valid JSON whose root is not an object must not throw (Try* contract). + // Act + bool result = ConnectorTriggerPayload.TryReadBinaryContent(payload, out byte[] content); + + // Assert + Assert.IsFalse(result); + Assert.AreEqual(0, content.Length); + } + + [TestMethod] + public async Task ReadAsync_BodyExceedsLimit_ThrowsInvalidOperation() + { + // Arrange — a payload larger than the (tiny) configured limit. + using var stream = new MemoryStream( + Encoding.UTF8.GetBytes(ConnectorTriggerPayloadTests.MetadataPascalCasePayload)); + + // Act & Assert + await Assert.ThrowsExactlyAsync( + async () => await ConnectorTriggerPayload + .ReadAsync(stream, maxBodySizeBytes: 8) + .ConfigureAwait(continueOnCapturedContext: false)) + .ConfigureAwait(continueOnCapturedContext: false); + } + + [TestMethod] + public async Task ReadAsync_OverLimitBody_DoesNotReadEntireStream() + { + // Arrange — a stream that records the largest single read it is asked for, so we can + // prove the reader never pulls far past the configured limit even when the chunk + // would otherwise be much larger. + byte[] payload = Encoding.UTF8.GetBytes(ConnectorTriggerPayloadTests.MetadataPascalCasePayload); + using var inner = new MemoryStream(payload); + using var tracking = new MaxReadTrackingStream(inner); + const long limit = 8; + + // Act & Assert + await Assert.ThrowsExactlyAsync( + async () => await ConnectorTriggerPayload + .ReadAsync(tracking, maxBodySizeBytes: limit) + .ConfigureAwait(continueOnCapturedContext: false)) + .ConfigureAwait(continueOnCapturedContext: false); + + // A single read must never be asked for more than the limit plus the one-byte + // overflow probe, regardless of the (much larger) internal chunk size. + Assert.IsTrue( + tracking.MaxRequestedCount <= limit + 1, + $"Expected no single read larger than {limit + 1} bytes, but observed {tracking.MaxRequestedCount}."); + } + + [TestMethod] + public async Task ReadAsync_VeryLargeLimit_DoesNotOverflow() + { + // Arrange — long.MaxValue effectively disables the limit. The internal read-cap + // arithmetic must not overflow (which would make the request size negative). + using var stream = new MemoryStream( + Encoding.UTF8.GetBytes(ConnectorTriggerPayloadTests.MetadataPascalCasePayload)); + + // Act + var payload = await ConnectorTriggerPayload + .ReadAsync(stream, maxBodySizeBytes: long.MaxValue) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsNotNull(payload); + Assert.IsNotNull(payload.Body); + Assert.IsNotNull(payload.Body.Value); + Assert.AreEqual(1, payload.Body.Value.Count); + Assert.AreEqual("report.docx", payload.Body.Value[0].Name); + } + + [TestMethod] + public async Task ReadBinaryContentAsync_DoesNotCloseCallerStream() + { + // Arrange + byte[] expected = Encoding.UTF8.GetBytes("keep me open"); + string base64 = Convert.ToBase64String(expected); + using var stream = new MemoryStream(Encoding.UTF8.GetBytes($$"""{"body":"{{base64}}"}""")); + + // Act + byte[]? content = await ConnectorTriggerPayload + .ReadBinaryContentAsync(stream) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert — the helper must not take ownership of the caller's stream. + Assert.IsNotNull(content); + CollectionAssert.AreEqual(expected, content); + Assert.IsTrue(stream.CanRead, "The caller-owned stream must remain open after reading."); + } + + [TestMethod] + public async Task ReadBinaryContentAsync_MetadataStream_ReturnsNull() + { + // Arrange + using var stream = new MemoryStream( + Encoding.UTF8.GetBytes(ConnectorTriggerPayloadTests.MetadataPascalCasePayload)); + + // Act + byte[]? content = await ConnectorTriggerPayload + .ReadBinaryContentAsync(stream) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert — an object body is not binary content. + Assert.IsNull(content); + } + + [TestMethod] + public async Task ReadBinaryContentAsync_MalformedJson_ReturnsNull() + { + // Arrange + using var stream = new MemoryStream(Encoding.UTF8.GetBytes("this is not json {")); + + // Act + byte[]? content = await ConnectorTriggerPayload + .ReadBinaryContentAsync(stream) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsNull(content); + } + + [TestMethod] + public async Task ReadBinaryContentAsync_NonBase64StringBody_ReturnsNull() + { + // Arrange + using var stream = new MemoryStream( + Encoding.UTF8.GetBytes("""{"body":"not valid base64 !!!"}""")); + + // Act + byte[]? content = await ConnectorTriggerPayload + .ReadBinaryContentAsync(stream) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsNull(content); + } + + [TestMethod] + [DataRow("null")] + [DataRow("[]")] + [DataRow("\"a string root\"")] + [DataRow("42")] + public async Task ReadBinaryContentAsync_NonObjectRoot_ReturnsNull(string payload) + { + // Arrange + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(payload)); + + // Act + byte[]? content = await ConnectorTriggerPayload + .ReadBinaryContentAsync(stream) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsNull(content); + } + + [TestMethod] + public async Task ReadBinaryContentAsync_ExtraQuotedBase64_DecodesBytes() + { + // Arrange — the Logic Apps expression engine can wrap the base64 string in extra quotes. + byte[] expected = Encoding.UTF8.GetBytes("extra quoted content"); + string base64 = Convert.ToBase64String(expected); + string payload = $$"""{"body":"\"{{base64}}\""}"""; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(payload)); + + // Act + byte[]? content = await ConnectorTriggerPayload + .ReadBinaryContentAsync(stream) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsNotNull(content); + CollectionAssert.AreEqual(expected, content); + } + } +} diff --git a/tests/Azure.Connectors.Sdk.Tests/MaxReadTrackingStream.cs b/tests/Azure.Connectors.Sdk.Tests/MaxReadTrackingStream.cs new file mode 100644 index 0000000..ec9b7ff --- /dev/null +++ b/tests/Azure.Connectors.Sdk.Tests/MaxReadTrackingStream.cs @@ -0,0 +1,71 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Azure.Connectors.Sdk.Tests +{ + /// + /// A pass-through that records the largest single read request it receives, + /// used to verify the reader caps each read to the configured maximum body size. + /// + internal sealed class MaxReadTrackingStream : Stream + { + private readonly Stream _inner; + + public MaxReadTrackingStream(Stream inner) + { + this._inner = inner; + } + + public int MaxRequestedCount { get; private set; } + + public override bool CanRead => this._inner.CanRead; + + public override bool CanSeek => this._inner.CanSeek; + + public override bool CanWrite => this._inner.CanWrite; + + public override long Length => this._inner.Length; + + public override long Position + { + get => this._inner.Position; + set => this._inner.Position = value; + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + this.MaxRequestedCount = Math.Max(this.MaxRequestedCount, buffer.Length); + return this._inner.ReadAsync(buffer, cancellationToken); + } + + public override int Read(byte[] buffer, int offset, int count) + { + this.MaxRequestedCount = Math.Max(this.MaxRequestedCount, count); + return this._inner.Read(buffer, offset, count); + } + + public override void Flush() => this._inner.Flush(); + + public override long Seek(long offset, SeekOrigin origin) => this._inner.Seek(offset, origin); + + public override void SetLength(long value) => this._inner.SetLength(value); + + public override void Write(byte[] buffer, int offset, int count) => this._inner.Write(buffer, offset, count); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + this._inner.Dispose(); + } + + base.Dispose(disposing); + } + } +}