diff --git a/TriasDev.Templify.Tests/Documentation/DeveloperGuideSamplesTests.cs b/TriasDev.Templify.Tests/Documentation/DeveloperGuideSamplesTests.cs index d90494e..ef5e95f 100644 --- a/TriasDev.Templify.Tests/Documentation/DeveloperGuideSamplesTests.cs +++ b/TriasDev.Templify.Tests/Documentation/DeveloperGuideSamplesTests.cs @@ -119,6 +119,49 @@ public void QuickStart_OtherInputAndOutputShapes_Work() } } + // quick-start.md "Web Applications (Async I/O)", FAQ.md "Is there an async API?" and + // Examples.md "Uploaded Templates (Async I/O)" + [Fact] + public async Task QuickStart_UploadBufferedAsynchronously_IsProcessed() + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + var processor = new DocumentTemplateProcessor(new PlaceholderReplacementOptions { Culture = _invariant }); + var data = new Dictionary { ["CustomerName"] = "Jane" }; + using AsyncOnlyStream uploadedFile = new AsyncOnlyStream(CreateTemplate("Hi {{CustomerName}}").ToArray()); + + using var templateStream = new MemoryStream(); + await uploadedFile.CopyToAsync(templateStream, cancellationToken); + templateStream.Position = 0; + + using var outputStream = new MemoryStream(); + ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data); + + Assert.True(result.IsSuccess); + Assert.Equal(new[] { "Hi Jane" }, ReadParagraphs(outputStream.ToArray())); + } + + // Examples.md "Uploaded Templates (Async I/O)": the format of the buffered upload selects the content type + [Fact] + public async Task Examples_UploadedTemplate_FormatIsDetectedAndProcessed() + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using AsyncOnlyStream template = new AsyncOnlyStream(CreateTemplate("Hi {{CustomerName}}").ToArray()); + + using var templateStream = new MemoryStream(); + await template.CopyToAsync(templateStream, cancellationToken); + templateStream.Position = 0; + + TemplateFormat format = TemplateProcessor.DetectFormat(templateStream); + + using var outputStream = new MemoryStream(); + ProcessingResult result = new TemplateProcessor().ProcessTemplate( + templateStream, outputStream, """{"CustomerName": "Jane"}"""); + + Assert.Equal(TemplateFormat.Docx, format); + Assert.True(result.IsSuccess); + Assert.Equal(new[] { "Hi Jane" }, ReadParagraphs(outputStream.ToArray())); + } + [Fact] public void QuickStart_WriteOnlyOutputStream_IsAFailedResult() { @@ -360,4 +403,55 @@ private sealed class WriteOnlyStream : MemoryStream { public override bool CanRead => false; } + + /// + /// A request/upload stream as in ASP.NET Core with AllowSynchronousIO = false: not seekable, and synchronous + /// reads throw. + /// + private sealed class AsyncOnlyStream : Stream + { + private readonly MemoryStream _content; + + public AsyncOnlyStream(byte[] content) => _content = new MemoryStream(content, writable: false); + + 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) => + throw new InvalidOperationException("Synchronous operations are disallowed."); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) => + _content.ReadAsync(buffer, cancellationToken); + + 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) + { + if (disposing) + { + _content.Dispose(); + } + + base.Dispose(disposing); + } + } } diff --git a/TriasDev.Templify/Examples.md b/TriasDev.Templify/Examples.md index f07649d..62d4a09 100644 --- a/TriasDev.Templify/Examples.md +++ b/TriasDev.Templify/Examples.md @@ -1750,6 +1750,43 @@ public class ContractData } ``` +### Uploaded Templates (Async I/O) + +The processing API is synchronous: processing runs in memory and typically takes milliseconds. Request streams in +ASP.NET Core do not allow synchronous reads (Kestrel's `AllowSynchronousIO` is `false`), so passing an upload stream +directly as the template fails. Buffer the upload asynchronously first, then process the buffer: + +```csharp +[HttpPost("render")] +public async Task Render(IFormFile template, [FromForm] string data, CancellationToken cancellationToken) +{ + // Read the upload asynchronously; the processor then only works on memory. + using var templateStream = new MemoryStream(); + await template.CopyToAsync(templateStream, cancellationToken); + templateStream.Position = 0; + + // TemplateProcessor accepts Word (.docx) and OpenDocument (.odt/.ott) templates. + TemplateFormat format = TemplateProcessor.DetectFormat(templateStream); + + using var outputStream = new MemoryStream(); + ProcessingResult result = new TemplateProcessor().ProcessTemplate(templateStream, outputStream, data); + + if (!result.IsSuccess) + { + return BadRequest(new { error = result.ErrorMessage }); + } + + // File(...) writes the response body asynchronously. + return format == TemplateFormat.Odt + ? File(outputStream.ToArray(), "application/vnd.oasis.opendocument.text", "document.odt") + : File(outputStream.ToArray(), + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "document.docx"); +} +``` + +The output stream cannot be `Response.Body` either: a Word output must be readable, writable and seekable. Write into a +`MemoryStream` and return it as shown. + ### Dependency Injection Setup ```csharp diff --git a/docs/FAQ.md b/docs/FAQ.md index 1183357..c9fbb77 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -72,6 +72,9 @@ syntax is the same, and LibreOffice does not need to be installed where the temp - Desktop applications - Console applications +In ASP.NET Core, buffer uploaded templates asynchronously before processing (see +[Is there an async API?](#q-is-there-an-async-api)). + --- ## Features & Capabilities @@ -564,6 +567,27 @@ size. See the [performance notes](https://github.com/TriasDev/templify/blob/main - Keep the template bytes in memory and use `ProcessTemplate(byte[] template, data, out byte[] output)` - The API is synchronous; process independent documents in parallel for throughput +### Q: Is there an async API? + +**A:** No, and none is planned for now. Processing is CPU-bound and runs in memory (typically milliseconds), so an +async method would only wrap synchronous work. The I/O around it is in your code, where it can be async: + +- **Input:** request and upload streams in ASP.NET Core do not allow synchronous reads. Copy them into a + `MemoryStream` with `await CopyToAsync(...)` and pass that stream as the template. +- **Output:** a Word output stream must be readable, writable and seekable, so write into a `MemoryStream` and return + it (`File(...)` in ASP.NET Core sends it asynchronously). + +```csharp +using var templateStream = new MemoryStream(); +await uploadedFile.CopyToAsync(templateStream, cancellationToken); +templateStream.Position = 0; + +using var outputStream = new MemoryStream(); +ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data); +``` + +For many documents, process them in parallel (see the next question). + ### Q: Can I process templates in parallel? **A:** **Yes!** A `DocumentTemplateProcessor` and its options can be shared by concurrent calls (register custom boolean formatters before sharing the options). Best practice: diff --git a/docs/for-developers/quick-start.md b/docs/for-developers/quick-start.md index 5af2138..bf99d23 100644 --- a/docs/for-developers/quick-start.md +++ b/docs/for-developers/quick-start.md @@ -114,6 +114,23 @@ Every overload (stream, `byte[]` and file) also accepts a JSON string instead of [Using JSON Data](#using-json-data)). `TextTemplateProcessor.ProcessTemplate` and `DocumentTemplateProcessor.ValidateTemplate` accept `IReadOnlyDictionary` data as well. +### Web Applications (Async I/O) + +The API is synchronous: processing is CPU-bound, runs in memory and typically takes milliseconds. Do the I/O around it +asynchronously. In ASP.NET Core, request and upload streams do not allow synchronous reads, so buffer an uploaded +template first: + +```csharp +using var templateStream = new MemoryStream(); +await uploadedFile.CopyToAsync(templateStream, cancellationToken); +templateStream.Position = 0; + +using var outputStream = new MemoryStream(); // a Word output must be readable, writable and seekable +ProcessingResult result = processor.ProcessTemplate(templateStream, outputStream, data); +``` + +See the [ASP.NET Core examples](https://github.com/TriasDev/templify/blob/main/TriasDev.Templify/Examples.md#web-application-integration). + ## Data ### Nested Data and Objects