From d53f6b742fb15541a34fa6c18e0bc9b7137ae696 Mon Sep 17 00:00:00 2001 From: jsneedles Date: Fri, 9 Jan 2026 12:04:02 -0500 Subject: [PATCH 1/9] wip --- .gitignore | 2 + Directory.Build.props | 2 +- HtmlCssToImage.slnx | 1 + .../Client/HtmlCssToImageClient.Images.cs | 103 ++++++ .../Client/HtmlCssToImageClient.Templates.cs | 129 +++++++ .../Client/HtmlCssToImageClient.Urls.cs | 205 +++++++++++ .../Client/HtmlCssToImageClient.cs | 50 +++ src/HtmlCssToImage/Helpers/ArrayOrSpan.cs | 70 ++++ src/HtmlCssToImage/Helpers/EnumToString.cs | 15 + src/HtmlCssToImage/Helpers/HmacToken.cs | 37 +- src/HtmlCssToImage/Helpers/MakeAuthHeader.cs | 42 +-- src/HtmlCssToImage/Helpers/NumberHelpers.cs | 28 ++ .../Helpers/QueryStringEncoder.cs | 212 ++++++++---- src/HtmlCssToImage/HtmlCssToImage.csproj | 3 + src/HtmlCssToImage/HtmlCssToImageClient.cs | 327 ------------------ src/HtmlCssToImage/IHtmlCssToImageClient.cs | 41 +++ src/HtmlCssToImage/JsonContext.cs | 13 +- .../Requests/CreateImageCommonOptions.cs | 4 - .../Models/Requests/CreateTemplateRequest.cs | 96 +++++ .../Models/Requests/CreateUrlImageRequest.cs | 5 + .../Responses/CreateTemplateResponse.cs | 9 + .../Models/Responses/PaginatedResponse.cs | 25 ++ src/HtmlCssToImage/Models/Template.cs | 128 +++++++ .../Benchmarks/HmacBenchmark.cs | 32 ++ .../Benchmarks/MakeAuthHeaderBenchmark.cs | 29 ++ .../Benchmarks/QueryStringEncoderBenchmark.cs | 62 ++++ .../Benchmarks/TemplateListUrlBenchmark.cs | 32 ++ .../HtmlCssToImage.Benchmarks.csproj | 18 + .../HtmlCssToImage.Benchmarks/Program.cs | 77 +++++ .../HtmlCssToImageClientTemplatesTests.cs | 154 +++++++++ 30 files changed, 1471 insertions(+), 480 deletions(-) create mode 100644 src/HtmlCssToImage/Client/HtmlCssToImageClient.Images.cs create mode 100644 src/HtmlCssToImage/Client/HtmlCssToImageClient.Templates.cs create mode 100644 src/HtmlCssToImage/Client/HtmlCssToImageClient.Urls.cs create mode 100644 src/HtmlCssToImage/Client/HtmlCssToImageClient.cs create mode 100644 src/HtmlCssToImage/Helpers/ArrayOrSpan.cs create mode 100644 src/HtmlCssToImage/Helpers/EnumToString.cs create mode 100644 src/HtmlCssToImage/Helpers/NumberHelpers.cs delete mode 100644 src/HtmlCssToImage/HtmlCssToImageClient.cs create mode 100644 src/HtmlCssToImage/Models/Requests/CreateTemplateRequest.cs create mode 100644 src/HtmlCssToImage/Models/Responses/CreateTemplateResponse.cs create mode 100644 src/HtmlCssToImage/Models/Responses/PaginatedResponse.cs create mode 100644 src/HtmlCssToImage/Models/Template.cs create mode 100644 src/tests/HtmlCssToImage.Benchmarks/Benchmarks/HmacBenchmark.cs create mode 100644 src/tests/HtmlCssToImage.Benchmarks/Benchmarks/MakeAuthHeaderBenchmark.cs create mode 100644 src/tests/HtmlCssToImage.Benchmarks/Benchmarks/QueryStringEncoderBenchmark.cs create mode 100644 src/tests/HtmlCssToImage.Benchmarks/Benchmarks/TemplateListUrlBenchmark.cs create mode 100644 src/tests/HtmlCssToImage.Benchmarks/HtmlCssToImage.Benchmarks.csproj create mode 100644 src/tests/HtmlCssToImage.Benchmarks/Program.cs create mode 100644 src/tests/HtmlCssToImage.Tests/HtmlCssToImageClientTemplatesTests.cs diff --git a/.gitignore b/.gitignore index e8dbe8f..922c4a9 100644 --- a/.gitignore +++ b/.gitignore @@ -260,3 +260,5 @@ paket-files/ output *.DS_STORE + +**/BenchmarkDotNet.Artifacts/** \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index a3f0a20..927524d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - 0.0.8 + 0.0.9 Code Happy, LLC Code Happy, LLC HTML/CSS To Image API diff --git a/HtmlCssToImage.slnx b/HtmlCssToImage.slnx index 41a3511..111ac44 100644 --- a/HtmlCssToImage.slnx +++ b/HtmlCssToImage.slnx @@ -15,6 +15,7 @@ + diff --git a/src/HtmlCssToImage/Client/HtmlCssToImageClient.Images.cs b/src/HtmlCssToImage/Client/HtmlCssToImageClient.Images.cs new file mode 100644 index 0000000..dff4513 --- /dev/null +++ b/src/HtmlCssToImage/Client/HtmlCssToImageClient.Images.cs @@ -0,0 +1,103 @@ +using System.Diagnostics; +using System.Net.Http.Json; +using HtmlCssToImage.Models.Requests; +using HtmlCssToImage.Models.Responses; +using HtmlCssToImage.Models.Results; + +namespace HtmlCssToImage; + +public partial class HtmlCssToImageClient +{ + /// + public async Task> CreateImageBatchAsync(T? defaultOptions, IEnumerable variations, + CancellationToken cancellationToken = default) where T : IBatchAllowedImageRequest + { + var request = new CreateImageBatchRequest + { + DefaultOptions = defaultOptions + }; + request.Variations.AddRange(variations); + return await CreateImageBatchAsync(request, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task> CreateImageBatchAsync(CreateImageBatchRequest request, + CancellationToken cancellationToken = default) where T : IBatchAllowedImageRequest + { + HttpResponseMessage response; + if (request is CreateImageBatchRequest url_request) + { + response = await _client.PostAsJsonAsync(CREATE_BATCH_URL, url_request, JsonContext.Default.CreateImageBatchRequestCreateUrlImageRequest, cancellationToken).ConfigureAwait(false); + } + else if (request is CreateImageBatchRequest html_request) + { + response = await _client.PostAsJsonAsync(CREATE_BATCH_URL, html_request, JsonContext.Default.CreateImageBatchRequestCreateHtmlCssImageRequest, cancellationToken).ConfigureAwait(false); + } + else + { + throw new UnreachableException(); + } + + var result = new ApiResult() + { + HttpResponseMessage = response, + StatusCode = (int)response.StatusCode, + Success = response.IsSuccessStatusCode + }; + if (response.IsSuccessStatusCode) + { + var response_data = + await response.Content.ReadFromJsonAsync( + JsonContext.Default.CreateImageBatchResponse, cancellationToken); + result.Response = response_data?.Images ?? []; + } + else + { + result.ErrorDetails = await response.Content.ReadFromJsonAsync(JsonContext.Default.ErrorDetails, cancellationToken); + } + + return result; + } + + /// + public async Task> CreateImageAsync(T request, CancellationToken cancellationToken = default) where T : ICreateImageRequestBase + { + HttpResponseMessage response; + if (request is CreateTemplatedImageRequest templated_request) + { + response = await _client.PostAsJsonAsync(CREATE_URL, templated_request, + JsonContext.Default.CreateTemplatedImageRequest, cancellationToken).ConfigureAwait(false); + } + else if (request is CreateHtmlCssImageRequest html_css_request) + { + response = await _client.PostAsJsonAsync(CREATE_URL, html_css_request, + JsonContext.Default.CreateHtmlCssImageRequest, cancellationToken).ConfigureAwait(false); + } + else if (request is CreateUrlImageRequest url_request) + { + response = await _client.PostAsJsonAsync(CREATE_URL, url_request, + JsonContext.Default.CreateUrlImageRequest, cancellationToken).ConfigureAwait(false); + } + else + { + throw new UnreachableException(); + } + + var result = new ApiResult() + { + HttpResponseMessage = response, + StatusCode = (int)response.StatusCode, + Success = response.IsSuccessStatusCode + }; + if (response.IsSuccessStatusCode) + { + result.Response = await response.Content.ReadFromJsonAsync(JsonContext.Default.CreateImageResponse, cancellationToken).ConfigureAwait(false); + } + else + { + result.ErrorDetails = await response.Content.ReadFromJsonAsync(JsonContext.Default.ErrorDetails, cancellationToken).ConfigureAwait(false); + } + + return result; + } +} \ No newline at end of file diff --git a/src/HtmlCssToImage/Client/HtmlCssToImageClient.Templates.cs b/src/HtmlCssToImage/Client/HtmlCssToImageClient.Templates.cs new file mode 100644 index 0000000..0c59dba --- /dev/null +++ b/src/HtmlCssToImage/Client/HtmlCssToImageClient.Templates.cs @@ -0,0 +1,129 @@ +using System.Net.Http.Json; +using System.Text; +using HtmlCssToImage.Helpers; +using HtmlCssToImage.Models; +using HtmlCssToImage.Models.Requests; +using HtmlCssToImage.Models.Responses; +using HtmlCssToImage.Models.Results; + +namespace HtmlCssToImage; + +public partial class HtmlCssToImageClient +{ + internal const string TEMPLATE_BASE_URL = $"{HOST}/v1/template"; + + /// + public Task> CreateTemplateAsync(CreateTemplateRequest request, CancellationToken cancellationToken = default) => CreateTemplateCore(request, cancellationToken: cancellationToken); + + /// + public Task> CreateTemplateVersionAsync(string templateId, CreateTemplateRequest request, CancellationToken cancellationToken = default) => CreateTemplateCore(request, templateId, cancellationToken); + + private async Task> CreateTemplateCore(CreateTemplateRequest request, string? templateId = null, CancellationToken cancellationToken = default) + { + var url = templateId == null ? TEMPLATE_BASE_URL : $"{TEMPLATE_BASE_URL}/{templateId}"; + + var response =await _client.PostAsJsonAsync(url, request, JsonContext.Default.CreateTemplateRequest, cancellationToken).ConfigureAwait(false); + + var result = new ApiResult() + { + HttpResponseMessage = response, + StatusCode = (int)response.StatusCode, + Success = response.IsSuccessStatusCode + }; + if (response.IsSuccessStatusCode) + { + var response_data = + await response.Content.ReadFromJsonAsync( + JsonContext.Default.CreateTemplateResponse, cancellationToken).ConfigureAwait(false); + result.Response = response_data; + } + else + { + result.ErrorDetails = await response.Content.ReadFromJsonAsync(JsonContext.Default.ErrorDetails, cancellationToken).ConfigureAwait(false); + } + + return result; + } + + /// + public Task?>> ListTemplateVersionsAsync(string templateId, int count = 10, long? nextPageStart = null, CancellationToken cancellationToken = default)=>ListTemplatesCore(templateId, (uint)count, nextPageStart, cancellationToken); + + /// + public Task?>> ListTemplatesAsync(int count = 10, long? nextPageStart = null, CancellationToken cancellationToken = default)=>ListTemplatesCore(null, (uint)count, nextPageStart, cancellationToken); + + private async Task?>> ListTemplatesCore(string? templateId, uint count = 10, long? nextPageStart = null, CancellationToken cancellationToken = default) + { + if (count == 0) + { + count = 10; + } + if (count > 100) + { + count = 100; + } + + var url = GetTemplateListUrl(templateId, count, nextPageStart); + //var url = $"{TEMPLATE_BASE_URL}{(string.IsNullOrEmpty(templateId)?"":$"/{templateId}")}?count={count}&max_version={nextPageStart ?? long.MaxValue}"; + + var response = await _client.GetAsync(url, cancellationToken); + var result = new ApiResult?>() + { + HttpResponseMessage = response, + StatusCode = (int)response.StatusCode, + Success = response.IsSuccessStatusCode + }; + if (response.IsSuccessStatusCode) + { + result.Response = await response.Content.ReadFromJsonAsync>(JsonContext.Default.PaginatedResponseTemplate, cancellationToken).ConfigureAwait(false); + } + else + { + result.ErrorDetails = await response.Content.ReadFromJsonAsync(JsonContext.Default.ErrorDetails, cancellationToken).ConfigureAwait(false); + } + + return result; + + } + + + internal static string GetTemplateListUrl(string? templateId, uint count, long? nextPageStart) + { + var num_chars = TEMPLATE_BASE_URL.Length+7; + if (!string.IsNullOrEmpty(templateId)) + { + num_chars+=templateId.Length+1; + } + + num_chars += NumberHelpers.GetDigitsCount(count); + if (nextPageStart.HasValue) + { + num_chars += 13; + num_chars += NumberHelpers.GetDigitsCount((ulong)nextPageStart.Value); + } + + return string.Create(num_chars, (templateId, count, nextPageStart), (chars, state) => + { + var pos = 0; + TEMPLATE_BASE_URL.AsSpan().CopyTo(chars[pos..]); + pos += TEMPLATE_BASE_URL.Length; + if (!string.IsNullOrEmpty(state.templateId)) + { + chars[pos++] = '/'; + state.templateId.AsSpan().CopyTo(chars[pos..]); + pos += state.templateId.Length; + } + + "?count=".AsSpan().CopyTo(chars[pos..]); + pos += 7; + state.count.TryFormat(chars[pos..], out int cW); + pos += cW; + if (state.nextPageStart.HasValue) + { + "&max_version=".AsSpan().CopyTo(chars[pos..]); + pos += 13; + state.nextPageStart.Value.TryFormat(chars[pos..], out int vW); + pos += vW; + } + }); + } +} \ No newline at end of file diff --git a/src/HtmlCssToImage/Client/HtmlCssToImageClient.Urls.cs b/src/HtmlCssToImage/Client/HtmlCssToImageClient.Urls.cs new file mode 100644 index 0000000..28d8f26 --- /dev/null +++ b/src/HtmlCssToImage/Client/HtmlCssToImageClient.Urls.cs @@ -0,0 +1,205 @@ +using System.Buffers; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; +using HtmlCssToImage.Helpers; +using HtmlCssToImage.Models; +using HtmlCssToImage.Models.Requests; + +namespace HtmlCssToImage; + +public partial class HtmlCssToImageClient +{ + /// + public string CreateTemplatedImageUrl(string templateId, T templateValues, + JsonTypeInfo typeInfo, long? templateVersion = null, RenderImageFormat format = RenderImageFormat.PNG) => CreateTemplatedImageUrl(templateId, templateValues, templateVersion, typeInfo, null, format); + + /// + [RequiresUnreferencedCode("If AOT is needed, use one of the overloads with explicit type information")] + [RequiresDynamicCode("If AOT is needed, use one of the overloads with explicit type information")] + public string CreateTemplatedImageUrl(string templateId, T templateValues, long? templateVersion = null, RenderImageFormat format = RenderImageFormat.PNG) => + CreateTemplateImageUrlNoTypeInfo(templateId, templateValues, templateVersion, format); + + /// + public string CreateTemplatedImageUrl(string templateId, T templateValues, + JsonSerializerOptions jsonSerializerOptions, long? templateVersion = null, RenderImageFormat format = RenderImageFormat.PNG) => + CreateTemplatedImageUrl(templateId, templateValues, templateVersion, null, jsonSerializerOptions, format); + + [RequiresUnreferencedCode("If AOT is needed, use one of the overloads with explicit type information")] + [RequiresDynamicCode("If AOT is needed, use one of the overloads with explicit type information")] + private string CreateTemplateImageUrlNoTypeInfo(string templateId, T templateValues, long? templateVersion = null, RenderImageFormat format = RenderImageFormat.PNG) + { + var serialized_values = JsonSerializer.SerializeToNode(templateValues); + if (serialized_values == null || serialized_values.GetValueKind() != JsonValueKind.Object) + { + throw new ArgumentException("Invalid parameter values"); + } + + return CreateTemplatedImageUrl(templateId, serialized_values.AsObject(), templateVersion, format); + } + + private string CreateTemplatedImageUrl(string templateId, T templateValues, long? templateVersion = null, + JsonTypeInfo? typeInfo = null, JsonSerializerOptions? jsonSerializerOptions = null, RenderImageFormat format = RenderImageFormat.PNG) + { + JsonNode? serialized_values; + if (typeInfo != null) + { + serialized_values = JsonSerializer.SerializeToNode(templateValues, typeInfo); + } + else if (jsonSerializerOptions != null) + { + serialized_values = JsonSerializer.SerializeToNode(templateValues, jsonSerializerOptions.GetTypeInfo(typeof(T))); + } + else + { + throw new ArgumentException("Must provide either typeInfo or jsonSerializerOptions"); + } + + if (serialized_values == null || serialized_values.GetValueKind() != JsonValueKind.Object) + { + throw new ArgumentException("Invalid parameter values"); + } + + return CreateTemplatedImageUrl(templateId, serialized_values.AsObject(), templateVersion, format); + } + + /// + public string CreateTemplatedImageUrl(string templateId, JsonObject templateValues, long? templateVersion = null, RenderImageFormat format = RenderImageFormat.PNG) + { + ArrayOrSpan chars = new(stackalloc char[512]); + try + { + if (templateVersion.HasValue) + { + QueryStringEncoder.WriteSafeKey(TEMPLATE_VERSION_QUERY_PARAM, templateVersion.Value, ref chars); + } + + foreach (var (key, value) in templateValues.OrderBy(x => x.Key)) + { + if (value is not null) + { + QueryStringEncoder.Encode(key, value.ToJsonString(), ref chars); + } + } + + var token = HmacToken.CreateToken(chars.LimitedSpan, _apiKey); + var format_string = string.Empty; + if (format != RenderImageFormat.PNG) + { + format_string = $"/{format.RenderFormatToExtensionWithoutDot()}"; + } + + var url = $"{CREATE_PATH}/{templateId}/{token}{format_string}?{chars.LimitedSpan}"; + + return url; + } + finally + { + chars.Dispose(); + } + + } + + internal static void CreateAndRenderUrlQueryString(CreateUrlImageRequest request, ref ArrayOrSpan chars) + { + QueryStringEncoder.EncodeSafeKey("url", request.Url, ref chars); + + if (request.FullScreen == true) + { + QueryStringEncoder.EncodeSafeKeyValue("full_screen", "true", ref chars); + } + + if (request.BlockConsentBanners == true) + { + QueryStringEncoder.EncodeSafeKeyValue("block_consent_banners", "true", ref chars); + } + + if (request.DisableTwemoji == true) + { + QueryStringEncoder.EncodeSafeKeyValue("disable_twemoji", "true", ref chars); + } + + if (request.MaxRenderOnce == true) + { + QueryStringEncoder.EncodeSafeKeyValue("max_render_once", "true", ref chars); + } + + if (request.RenderWhenReady == true) + { + QueryStringEncoder.EncodeSafeKeyValue("render_when_ready", "true", ref chars); + } + + if (request.ColorScheme != null) + { + QueryStringEncoder.EncodeSafeKeyValue("color_scheme", Helpers.EnumToString.ColorSchemeString(request.ColorScheme.Value), ref chars); + } + + if (request.DeviceScale != null) + { + QueryStringEncoder.WriteSafeKey("device_scale", request.DeviceScale.Value, ref chars); + } + + if (request.MaxWaitMs != null) + { + QueryStringEncoder.WriteSafeKey("max_wait_ms", request.MaxWaitMs.Value, ref chars); + } + + if (request.MsDelay != null) + { + QueryStringEncoder.WriteSafeKey("ms_delay", request.MsDelay.Value, ref chars); + } + + if (request.ViewportHeight != null) + { + QueryStringEncoder.WriteSafeKey("viewport_height", request.ViewportHeight.Value, ref chars); + } + + if (request.ViewportWidth != null) + { + QueryStringEncoder.WriteSafeKey("viewport_width", request.ViewportWidth.Value, ref chars); + } + + if (!string.IsNullOrWhiteSpace(request.Css)) + { + QueryStringEncoder.EncodeSafeKey("css", request.Css, ref chars); + } + + if (!string.IsNullOrWhiteSpace(request.Selector)) + { + QueryStringEncoder.EncodeSafeKey("selector", request.Selector, ref chars); + } + + if (!string.IsNullOrWhiteSpace(request.Timezone)) + { + QueryStringEncoder.EncodeSafeKey("timezone", request.Timezone, ref chars); + } + } + + /// + public string CreateAndRenderUrl(CreateUrlImageRequest request, RenderImageFormat format = RenderImageFormat.PNG) + { + + ArrayOrSpan chars = new(stackalloc char[512]); + try + { + CreateAndRenderUrlQueryString(request, ref chars); + var token = HmacToken.CreateToken(chars.LimitedSpan, _apiKey); + + var format_string = string.Empty; + if (format != RenderImageFormat.PNG) + { + format_string = $"/{format.RenderFormatToExtensionWithoutDot()}"; + } + + var url = $"{CREATE_AND_RENDER_PATH}/{_apiId}/{token}{format_string}?{chars.LimitedSpan}"; + + return url; + } + finally + { + chars.Dispose(); + } + + } +} \ No newline at end of file diff --git a/src/HtmlCssToImage/Client/HtmlCssToImageClient.cs b/src/HtmlCssToImage/Client/HtmlCssToImageClient.cs new file mode 100644 index 0000000..42e0435 --- /dev/null +++ b/src/HtmlCssToImage/Client/HtmlCssToImageClient.cs @@ -0,0 +1,50 @@ +using System.Buffers; +using System.Buffers.Text; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; +using System.Web; +using HtmlCssToImage.Helpers; +using HtmlCssToImage.Models; +using HtmlCssToImage.Models.Requests; +using HtmlCssToImage.Models.Responses; +using HtmlCssToImage.Models.Results; + +namespace HtmlCssToImage; + +/// +public partial class HtmlCssToImageClient : IHtmlCssToImageClient +{ + private readonly HttpClient _client; + + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + private readonly string _apiKey; + + private readonly string _apiId; + + + /// + /// A client for interacting with the HtmlCssToImage service, providing functionality for creating images using HTML and CSS input, managing templates, and generating rendered image URLs. + /// + public HtmlCssToImageClient(HttpClient client, HtmlCssToImageOptions options) + { + _client = client; + _client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("HCTIDotNet", LibraryInfo.Version)); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", options.AuthHeader()); + _apiKey = options.ApiKey; + _apiId = options.ApiId; + } + + private const string HOST = "https://hcti.io"; + private const string CREATE_PATH = $"{HOST}/v1/image"; + private const string CREATE_AND_RENDER_PATH = $"{CREATE_PATH}/create-and-render"; + private const string CREATE_URL = $"{CREATE_PATH}?includeId=true"; + private const string CREATE_BATCH_URL = $"{CREATE_PATH}/batch"; + + private const string TEMPLATE_VERSION_QUERY_PARAM = "template_version"; +} \ No newline at end of file diff --git a/src/HtmlCssToImage/Helpers/ArrayOrSpan.cs b/src/HtmlCssToImage/Helpers/ArrayOrSpan.cs new file mode 100644 index 0000000..fc063b8 --- /dev/null +++ b/src/HtmlCssToImage/Helpers/ArrayOrSpan.cs @@ -0,0 +1,70 @@ +using System.Buffers; +using System.Runtime.CompilerServices; + +namespace HtmlCssToImage.Helpers; + +internal ref struct ArrayOrSpan:IDisposable +{ + private T[]? _rented; + public Span Span; + + public Span RemainingSpan + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => Span[Position..]; + } + + + public Span LimitedSpan => Span[..Position]; + + public int Position; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ArrayOrSpan(Span initialBuffer) + { + Span = initialBuffer; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ArrayOrSpan(int size) + { + _rented=ArrayPool.Shared.Rent(size); + Span = _rented.AsSpan(); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Dispose() + { + if (_rented != null) + { + ArrayPool.Shared.Return(_rented); + _rented = null; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Advance(int count) + { + Position += count; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void EnsureCapacity(int additionalCapacity) + { + int requiredCapacity = Position + additionalCapacity; + if (requiredCapacity > Span.Length) + { + var newSize = requiredCapacity + 128; + var newRented = ArrayPool.Shared.Rent(newSize); + + Span[..Position].CopyTo(newRented); + + if (_rented != null) + { + ArrayPool.Shared.Return(_rented); + } + + _rented = newRented; + Span = _rented; + } + } +} \ No newline at end of file diff --git a/src/HtmlCssToImage/Helpers/EnumToString.cs b/src/HtmlCssToImage/Helpers/EnumToString.cs new file mode 100644 index 0000000..7d80258 --- /dev/null +++ b/src/HtmlCssToImage/Helpers/EnumToString.cs @@ -0,0 +1,15 @@ +using System.Runtime.CompilerServices; +using HtmlCssToImage.Models; + +namespace HtmlCssToImage.Helpers; + +internal static class EnumToString +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static string ColorSchemeString(ColorSchemeType type) => type switch + { + ColorSchemeType.dark => nameof(ColorSchemeType.dark), + ColorSchemeType.light => nameof(ColorSchemeType.light), + _ => throw new ArgumentOutOfRangeException(nameof(type), type, null) + }; +} \ No newline at end of file diff --git a/src/HtmlCssToImage/Helpers/HmacToken.cs b/src/HtmlCssToImage/Helpers/HmacToken.cs index 51c34b3..fdbf619 100644 --- a/src/HtmlCssToImage/Helpers/HmacToken.cs +++ b/src/HtmlCssToImage/Helpers/HmacToken.cs @@ -11,41 +11,16 @@ internal static string CreateToken(in ReadOnlySpan message, in ReadOnlySpa { var max_b_message = Encoding.UTF8.GetMaxByteCount(message.Length); var max_b_secret = Encoding.UTF8.GetMaxByteCount(secret.Length); - byte[]? msg_arr = null; - byte[]? secret_arr = null; - Span msg_buffer = max_b_message < 256 - ? stackalloc byte[max_b_message] - : []; - Span secret_buffer = max_b_secret < 256 - ? stackalloc byte[max_b_secret] - : []; - if (max_b_message >= 256) - { - msg_arr = ArrayPool.Shared.Rent(max_b_message); - msg_buffer = msg_arr.AsSpan(); - } - if (max_b_secret >= 256) - { - secret_arr = ArrayPool.Shared.Rent(max_b_secret); - secret_buffer = secret_arr.AsSpan(); - } + using ArrayOrSpan msg_buffer = max_b_message<=256? new(stackalloc byte[256]): new(max_b_message); + using ArrayOrSpan secret_buffer = max_b_secret<=256? new(stackalloc byte[256]): new(max_b_secret); + Span hashed_b = stackalloc byte[32]; - var msg_bw = Encoding.UTF8.GetBytes(message, msg_buffer); - var sec_bw = Encoding.UTF8.GetBytes(secret, secret_buffer); - HMACSHA256.HashData(secret_buffer.Slice(0, sec_bw), msg_buffer.Slice(0, msg_bw), hashed_b); + var msg_bw = Encoding.UTF8.GetBytes(message, msg_buffer.Span); + var sec_bw = Encoding.UTF8.GetBytes(secret, secret_buffer.Span); + HMACSHA256.HashData(secret_buffer.Span.Slice(0, sec_bw), msg_buffer.Span.Slice(0, msg_bw), hashed_b); var final = Convert.ToHexStringLower(hashed_b); - if (msg_arr != null) - { - ArrayPool.Shared.Return(msg_arr); - } - - if (secret_arr != null) - { - ArrayPool.Shared.Return(secret_arr); - } - return final; } } \ No newline at end of file diff --git a/src/HtmlCssToImage/Helpers/MakeAuthHeader.cs b/src/HtmlCssToImage/Helpers/MakeAuthHeader.cs index 89f5597..8a17e5c 100644 --- a/src/HtmlCssToImage/Helpers/MakeAuthHeader.cs +++ b/src/HtmlCssToImage/Helpers/MakeAuthHeader.cs @@ -11,45 +11,13 @@ internal static string AuthHeader(this HtmlCssToImageOptions options) { var rawByteCount = Encoding.UTF8.GetByteCount(options.ApiId) + 1 + Encoding.UTF8.GetByteCount(options.ApiKey); - byte[]? rawArrayFromPool = null; - Span rawBytes = rawByteCount <= 256 - ? stackalloc byte[256] - : (rawArrayFromPool = ArrayPool.Shared.Rent(rawByteCount)); - try - { - int written = Encoding.UTF8.GetBytes(options.ApiId, rawBytes); - rawBytes[written++] = (byte)':'; - written += Encoding.UTF8.GetBytes(options.ApiKey, rawBytes[written..]); + using ArrayOrSpan rawBytes = rawByteCount <= 256 ? new(stackalloc byte[256]) : new(rawByteCount); - // Base64 length calculation - int base64ByteCount = Base64.GetMaxEncodedToUtf8Length(written); - - byte[]? base64ArrayFromPool = null; - Span base64Bytes = base64ByteCount <= 512 - ? stackalloc byte[512] - : (base64ArrayFromPool = ArrayPool.Shared.Rent(base64ByteCount)); - - try - { - Base64.EncodeToUtf8(rawBytes[..written], base64Bytes, out _, out int bytesWritten); - return Encoding.UTF8.GetString(base64Bytes[..bytesWritten]); - } - finally - { - if (base64ArrayFromPool != null) - { - ArrayPool.Shared.Return(base64ArrayFromPool); - } - } - } - finally - { - if (rawArrayFromPool != null) - { - ArrayPool.Shared.Return(rawArrayFromPool); - } - } + int written = Encoding.UTF8.GetBytes(options.ApiId, rawBytes.Span); + rawBytes.Span[written++] = (byte)':'; + written += Encoding.UTF8.GetBytes(options.ApiKey, rawBytes.Span[written..]); + return Convert.ToBase64String(rawBytes.Span[..written]); } } \ No newline at end of file diff --git a/src/HtmlCssToImage/Helpers/NumberHelpers.cs b/src/HtmlCssToImage/Helpers/NumberHelpers.cs new file mode 100644 index 0000000..5cee334 --- /dev/null +++ b/src/HtmlCssToImage/Helpers/NumberHelpers.cs @@ -0,0 +1,28 @@ +using System.Numerics; +using System.Runtime.CompilerServices; + +namespace HtmlCssToImage.Helpers; + +internal static class NumberHelpers +{ + private static readonly ulong[] _powersOf10 = + [ + 1u, 10u, 100u, 1000u, 10000u, 100000u, 1000000u, 10000000u, 100000000u, + 1000000000u, 10000000000u, 100000000000u, 1000000000000u, + 10000000000000u, 100000000000000u, 1000000000000000u, + 10000000000000000u, 100000000000000000u, 1000000000000000000u, + 10000000000000000000u + ]; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static int GetDigitsCount(ulong value) + { + if (value == 0) + { + return 1; + } + + int log2 = BitOperations.Log2(value); + int log10 = (log2 * 1233) >> 12; + return value >= _powersOf10[log10 + 1] ? log10 + 2 : log10 + 1; + } +} \ No newline at end of file diff --git a/src/HtmlCssToImage/Helpers/QueryStringEncoder.cs b/src/HtmlCssToImage/Helpers/QueryStringEncoder.cs index a1f8c8e..80b1fc2 100644 --- a/src/HtmlCssToImage/Helpers/QueryStringEncoder.cs +++ b/src/HtmlCssToImage/Helpers/QueryStringEncoder.cs @@ -8,50 +8,74 @@ namespace HtmlCssToImage.Helpers; internal static class QueryStringEncoder { - private static readonly SearchValues urlSafeBytes = SearchValues.Create( - "!()*-.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz"); + private static readonly SearchValues urlSafeChars = SearchValues.Create( + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz-.~"); - public static void Write(string key, T value, ref int currCharPosition, ref Span destination, ref char[]? rented) where T : INumber + public static void Write(string key, T value, ref ArrayOrSpan buffer) where T : INumber { - - var max_key_length= GetEncodedLength(key.AsSpan(), out var key_needs_encoding); + var max_key_length = GetEncodedLength(key.AsSpan(), out var key_needs_encoding); var value_length_max = GetMaxChars(); - var max_length = max_key_length + value_length_max +1; - if (currCharPosition > 0) - { - // add one for the '&' - max_length += 1; - } - EnsureCapacity(currCharPosition + max_length, ref currCharPosition, ref destination, ref rented); - if (currCharPosition > 0) + var required = max_key_length + value_length_max + 1 + (buffer.Position > 0 ? 1 : 0); + + buffer.EnsureCapacity(required); + + if (buffer.Position > 0) { - destination[currCharPosition++] = '&'; + buffer.Span[buffer.Position++] = '&'; } + + if (!key_needs_encoding) { - key.AsSpan().CopyTo(destination[currCharPosition..]); - currCharPosition += key.Length; + key.AsSpan().CopyTo(buffer.RemainingSpan); + buffer.Advance(key.Length); } else { - currCharPosition += EncodeCore(key, destination[currCharPosition..]); + buffer.Advance(EncodeCore(key, buffer.RemainingSpan)); } - destination[currCharPosition++] = '='; - if (!value.TryFormat(destination[currCharPosition..], out var chars_written, "R", CultureInfo.InvariantCulture)) + + buffer.Span[buffer.Position++] = '='; + if (!value.TryFormat(buffer.RemainingSpan, out var chars_written, "R", CultureInfo.InvariantCulture)) { throw new InvalidOperationException($"Could not format value {value} into query string for {key}"); } - currCharPosition += chars_written; + buffer.Advance(chars_written); + } + + public static void WriteSafeKey(ReadOnlySpan key, T value, ref ArrayOrSpan buffer) where T : INumber + { + var value_length_max = GetMaxChars(); + + var required = key.Length + value_length_max + 1 + (buffer.Position > 0 ? 1 : 0); + + buffer.EnsureCapacity(required); + + var span = buffer.Span; + var pos = buffer.Position; + if (pos > 0) + { + span[pos++] = '&'; + } + key.CopyTo(span[pos..]); + pos+=key.Length; + span[pos++] = '='; + if (!value.TryFormat(span[pos..], out var chars_written, "R", CultureInfo.InvariantCulture)) + { + throw new InvalidOperationException($"Could not format value {value} into query string for {key}"); + } + buffer.Position = pos+chars_written; } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int GetMaxChars() where T : INumber { - if (typeof(T) == typeof(int)) return 11; if (typeof(T) == typeof(uint)) return 10; if (typeof(T) == typeof(long)) return 20; @@ -63,53 +87,95 @@ private static int GetMaxChars() return 32; // fallback } + public static void EncodeSafeKey(ReadOnlySpan key, ReadOnlySpan value, ref ArrayOrSpan buffer) + { + var max_value_length = GetEncodedLength(value, out var value_needs_encoding); + var required = key.Length + max_value_length + 1 + (buffer.Position > 0 ? 1 : 0); + buffer.EnsureCapacity(required); - public static void Encode(string key, string value, ref int currCharPosition, ref Span destination, ref char[]? rented) - { + var span = buffer.Span; + var pos = buffer.Position; + if (pos > 0) + { + span[pos++] = '&'; + } + key.CopyTo(span[pos..]); + pos+=key.Length; + span[pos++] = '='; - var max_key_length = GetEncodedLength(key.AsSpan(), out var key_needs_encoding); - var max_value_length = GetEncodedLength(value.AsSpan(), out var value_needs_encoding); + if (!value_needs_encoding) + { + value.CopyTo(span[pos..]); + pos+=value.Length; + } + else + { + pos += EncodeCore(value, span[pos..]); + } + buffer.Position = pos; + } + + public static void EncodeSafeKeyValue(ReadOnlySpan key, ReadOnlySpan value, ref ArrayOrSpan buffer) + { - var max_length = max_key_length + max_value_length +1; - if (currCharPosition > 0) + var required = key.Length + value.Length + 1 + (buffer.Position > 0 ? 1 : 0); + buffer.EnsureCapacity(required); + var span = buffer.Span; + var pos = buffer.Position; + if (pos > 0) { - //add one for the '&' - max_length += 1; + span[pos++] = '&'; } - EnsureCapacity(currCharPosition + max_length, ref currCharPosition, ref destination, ref rented); - if (currCharPosition > 0) + key.CopyTo(span[pos..]); + pos+=key.Length; + span[pos++] = '='; + + value.CopyTo(span[pos..]); + buffer.Position = pos+value.Length; + } + + public static void Encode(ReadOnlySpan key, ReadOnlySpan value, ref ArrayOrSpan buffer) + { + var max_key_length = GetEncodedLength(key, out var key_needs_encoding); + var max_value_length = GetEncodedLength(value, out var value_needs_encoding); + + var required = max_key_length + max_value_length + 1 + (buffer.Position > 0 ? 1 : 0); + + buffer.EnsureCapacity(required); + var span = buffer.Span; + var pos = buffer.Position; + if (pos > 0) { - destination[currCharPosition++] = '&'; + span[pos++] = '&'; } if (!key_needs_encoding) { - key.AsSpan().CopyTo(destination[currCharPosition..]); - currCharPosition += key.Length; + key.CopyTo(span[pos..]); + pos += key.Length; } else { - currCharPosition += EncodeCore(key, destination[currCharPosition..]); + pos += EncodeCore(key, span[pos..]); } - destination[currCharPosition++] = '='; + span[pos++] = '='; if (!value_needs_encoding) { - value.AsSpan().CopyTo(destination[currCharPosition..]); - currCharPosition += value.Length; + value.CopyTo(span[pos..]); + pos += value.Length; } else { - currCharPosition += EncodeCore(value, destination[currCharPosition..]); + pos += EncodeCore(value, span[pos..]); } - - + buffer.Position = pos; } private static int EncodeCore(ReadOnlySpan input, Span destination) @@ -118,8 +184,23 @@ private static int EncodeCore(ReadOnlySpan input, Span destination) Span utf8 = stackalloc byte[4]; int i = 0; - while (i 0) + { + input[i..(i + safeLength)].CopyTo(destination[written..]); + written += safeLength; + i += safeLength; + } + var rune_status = Rune.DecodeFromUtf16(input[i..], out Rune rune, out int charsConsumed); if (rune_status != OperationStatus.Done) { @@ -127,56 +208,36 @@ private static int EncodeCore(ReadOnlySpan input, Span destination) continue; } - if (rune.IsAscii && urlSafeBytes.Contains((char)rune.Value)) + if (rune.IsAscii && urlSafeChars.Contains((char)rune.Value)) { destination[written++] = (char)rune.Value; } else { - var utf8_len = rune.EncodeToUtf8(utf8); - for (var utf8_b = 0; utf8_b < utf8_len; utf8_b++) - { - var this_b = utf8[utf8_b]; - destination[written++] = '%'; - destination[written++] = GetHexValue(this_b >> 4); - destination[written++] = GetHexValue(this_b & 0xF); - } + var utf8_len = rune.EncodeToUtf8(utf8); + for (var utf8_b = 0; utf8_b < utf8_len; utf8_b++) + { + var this_b = utf8[utf8_b]; + destination[written++] = '%'; + destination[written++] = GetHexValue(this_b >> 4); + destination[written++] = GetHexValue(this_b & 0xF); + } } - i += charsConsumed; + i += charsConsumed; } return written; } - private static char GetHexValue(int i) => (char)(i < 10 ? i + '0' : i - 10 + 'A'); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void EnsureCapacity(int requiredCapacity, ref int currCharPosition, ref Span destination, ref char[]? rented) - { - if (destination.Length < requiredCapacity) - { - // Grow by at least 2x to avoid frequent re-allocations - int newSize = Math.Max(destination.Length * 2, requiredCapacity); - char[] newRented = ArrayPool.Shared.Rent(newSize); - - destination[..currCharPosition].CopyTo(newRented); - - if (rented != null) - { - ArrayPool.Shared.Return(rented); - } + private static char GetHexValue(int i) => (char)(i < 10 ? i + '0' : i - 10 + 'A'); - rented = newRented; - destination = rented; - } - } [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int GetEncodedLength(ReadOnlySpan span, out bool needsEncoding) { - int firstUnsafeIndex = span.IndexOfAnyExcept(urlSafeBytes); + int firstUnsafeIndex = span.IndexOfAnyExcept(urlSafeChars); if (firstUnsafeIndex == -1) { needsEncoding = false; @@ -194,7 +255,7 @@ private static int GetEncodedLength(ReadOnlySpan span, out bool needsEncod for (int i = firstUnsafeIndex; i < span.Length; i++) { char c = span[i]; - if (urlSafeBytes.Contains(c)) + if (urlSafeChars.Contains(c)) { length++; } @@ -210,6 +271,7 @@ private static int GetEncodedLength(ReadOnlySpan span, out bool needsEncod } } } + return length; } } \ No newline at end of file diff --git a/src/HtmlCssToImage/HtmlCssToImage.csproj b/src/HtmlCssToImage/HtmlCssToImage.csproj index a7e45ef..921d843 100644 --- a/src/HtmlCssToImage/HtmlCssToImage.csproj +++ b/src/HtmlCssToImage/HtmlCssToImage.csproj @@ -17,6 +17,9 @@ <_Parameter1>HtmlCssToImage.Tests + + <_Parameter1>HtmlCssToImage.Benchmarks + diff --git a/src/HtmlCssToImage/HtmlCssToImageClient.cs b/src/HtmlCssToImage/HtmlCssToImageClient.cs deleted file mode 100644 index 7137ffa..0000000 --- a/src/HtmlCssToImage/HtmlCssToImageClient.cs +++ /dev/null @@ -1,327 +0,0 @@ -using System.Buffers; -using System.Buffers.Text; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.Json.Serialization.Metadata; -using System.Web; -using HtmlCssToImage.Helpers; -using HtmlCssToImage.Models; -using HtmlCssToImage.Models.Requests; -using HtmlCssToImage.Models.Responses; -using HtmlCssToImage.Models.Results; - -namespace HtmlCssToImage; - -/// -public class HtmlCssToImageClient : IHtmlCssToImageClient -{ - private readonly HttpClient _client; - - [DebuggerBrowsable(DebuggerBrowsableState.Never)] - private readonly string _apiKey; - - private readonly string _apiId; - - - - /// - /// A client for interacting with the HtmlCssToImage service, providing functionality for creating images using HTML and CSS input, managing templates, and generating rendered image URLs. - /// - public HtmlCssToImageClient(HttpClient client, HtmlCssToImageOptions options) - { - _client = client; - _client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("HCTIDotNet", LibraryInfo.Version)); - _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", options.AuthHeader()); - _apiKey = options.ApiKey; - _apiId = options.ApiId; - } - - private const string HOST = "https://hcti.io"; - private const string CREATE_PATH = $"{HOST}/v1/image"; - private const string CREATE_AND_RENDER_PATH = $"{CREATE_PATH}/create-and-render"; - private const string CREATE_URL = $"{CREATE_PATH}?includeId=true"; - private const string CREATE_BATCH_URL = $"{CREATE_PATH}/batch"; - - private const string TEMPLATE_VERSION_QUERY_PARAM = "template_version"; - - /// - public async Task> CreateImageBatchAsync(T? defaultOptions, IEnumerable variations, - CancellationToken cancellationToken = default) where T : IBatchAllowedImageRequest - { - var request = new CreateImageBatchRequest - { - DefaultOptions = defaultOptions - }; - request.Variations.AddRange(variations); - return await CreateImageBatchAsync(request, cancellationToken).ConfigureAwait(false); - } - - /// - public async Task> CreateImageBatchAsync(CreateImageBatchRequest request, - CancellationToken cancellationToken = default) where T:IBatchAllowedImageRequest - { - HttpResponseMessage response; - if (request is CreateImageBatchRequest url_request) - { - response = await _client.PostAsJsonAsync(CREATE_BATCH_URL, url_request, JsonContext.Default.CreateImageBatchRequestCreateUrlImageRequest, cancellationToken).ConfigureAwait(false); - }else if (request is CreateImageBatchRequest html_request) - { - response = await _client.PostAsJsonAsync(CREATE_BATCH_URL, html_request, JsonContext.Default.CreateImageBatchRequestCreateHtmlCssImageRequest, cancellationToken).ConfigureAwait(false); - } - else - { - throw new UnreachableException(); - } - - var result = new ApiResult() - { - HttpResponseMessage = response, - StatusCode = (int)response.StatusCode, - Success = response.IsSuccessStatusCode - }; - if (response.IsSuccessStatusCode) - { - var response_data = - await response.Content.ReadFromJsonAsync( - JsonContext.Default.CreateImageBatchResponse, cancellationToken); - result.Response = response_data?.Images ?? []; - } - else - { - result.ErrorDetails = await response.Content.ReadFromJsonAsync(JsonContext.Default.ErrorDetails, cancellationToken); - } - - return result; - } - - /// - public async Task> CreateImageAsync(T request, CancellationToken cancellationToken = default) where T : ICreateImageRequestBase - { - HttpResponseMessage response; - if (request is CreateTemplatedImageRequest templated_request) - { - response = await _client.PostAsJsonAsync(CREATE_URL, templated_request, - JsonContext.Default.CreateTemplatedImageRequest, cancellationToken).ConfigureAwait(false); - } - else if (request is CreateHtmlCssImageRequest html_css_request) - { - response = await _client.PostAsJsonAsync(CREATE_URL, html_css_request, - JsonContext.Default.CreateHtmlCssImageRequest, cancellationToken).ConfigureAwait(false); - } - else if (request is CreateUrlImageRequest url_request) - { - response = await _client.PostAsJsonAsync(CREATE_URL, url_request, - JsonContext.Default.CreateUrlImageRequest, cancellationToken).ConfigureAwait(false); - } - else - { - throw new UnreachableException(); - } - - var result = new ApiResult() - { - HttpResponseMessage = response, - StatusCode = (int)response.StatusCode, - Success = response.IsSuccessStatusCode - }; - if (response.IsSuccessStatusCode) - { - result.Response = await response.Content.ReadFromJsonAsync(JsonContext.Default.CreateImageResponse, cancellationToken).ConfigureAwait(false); - } - else - { - result.ErrorDetails = await response.Content.ReadFromJsonAsync(JsonContext.Default.ErrorDetails, cancellationToken).ConfigureAwait(false); - } - - return result; - } - - /// - public string CreateTemplatedImageUrl(string templateId, T templateValues, - JsonTypeInfo typeInfo, long? templateVersion = null, RenderImageFormat format = RenderImageFormat.PNG) => CreateTemplatedImageUrl(templateId, templateValues, templateVersion, typeInfo, null, format); - - /// - [RequiresUnreferencedCode("If AOT is needed, use one of the overloads with explicit type information")] - [RequiresDynamicCode("If AOT is needed, use one of the overloads with explicit type information")] - public string CreateTemplatedImageUrl(string templateId, T templateValues, long? templateVersion = null, RenderImageFormat format = RenderImageFormat.PNG) => - CreateTemplateImageUrlNoTypeInfo(templateId, templateValues, templateVersion, format); - - /// - public string CreateTemplatedImageUrl(string templateId, T templateValues, - JsonSerializerOptions jsonSerializerOptions, long? templateVersion = null, RenderImageFormat format = RenderImageFormat.PNG) => - CreateTemplatedImageUrl(templateId, templateValues, templateVersion, null, jsonSerializerOptions, format); - - [RequiresUnreferencedCode("If AOT is needed, use one of the overloads with explicit type information")] - [RequiresDynamicCode("If AOT is needed, use one of the overloads with explicit type information")] - private string CreateTemplateImageUrlNoTypeInfo(string templateId, T templateValues, long? templateVersion = null, RenderImageFormat format = RenderImageFormat.PNG) - { - var serialized_values = JsonSerializer.SerializeToNode(templateValues); - if (serialized_values == null || serialized_values.GetValueKind() != JsonValueKind.Object) - { - throw new ArgumentException("Invalid parameter values"); - } - - return CreateTemplatedImageUrl(templateId, serialized_values.AsObject(), templateVersion, format); - } - - private string CreateTemplatedImageUrl(string templateId, T templateValues, long? templateVersion = null, - JsonTypeInfo? typeInfo = null, JsonSerializerOptions? jsonSerializerOptions = null, RenderImageFormat format = RenderImageFormat.PNG) - { - JsonNode? serialized_values; - if (typeInfo != null) - { - serialized_values = JsonSerializer.SerializeToNode(templateValues, typeInfo); - } - else if (jsonSerializerOptions != null) - { - serialized_values = JsonSerializer.SerializeToNode(templateValues, jsonSerializerOptions.GetTypeInfo(typeof(T))); - } - else - { - throw new ArgumentException("Must provide either typeInfo or jsonSerializerOptions"); - } - - if (serialized_values == null || serialized_values.GetValueKind() != JsonValueKind.Object) - { - throw new ArgumentException("Invalid parameter values"); - } - - return CreateTemplatedImageUrl(templateId, serialized_values.AsObject(), templateVersion, format); - } - - /// - public string CreateTemplatedImageUrl(string templateId, JsonObject templateValues, long? templateVersion = null, RenderImageFormat format = RenderImageFormat.PNG) - { - char[]? rented = null; - Span chars = stackalloc char[512]; - var curr_char = 0; - if (templateVersion.HasValue) - { - QueryStringEncoder.Write(TEMPLATE_VERSION_QUERY_PARAM, templateVersion.Value, ref curr_char, ref chars, ref rented); - } - - foreach (var (key, value) in templateValues.OrderBy(x => x.Key)) - { - if (value is not null) - { - QueryStringEncoder.Encode(key, value.ToJsonString(), ref curr_char, ref chars, ref rented); - } - } - - var token = HmacToken.CreateToken(chars[..curr_char], _apiKey); - var format_string = string.Empty; - if (format != RenderImageFormat.PNG) - { - format_string = $"/{format.RenderFormatToExtensionWithoutDot()}"; - } - - var url = $"{CREATE_PATH}/{templateId}/{token}{format_string}?{chars[..curr_char]}"; - - if (rented != null) - { - ArrayPool.Shared.Return(rented); - } - - return url; - } - - /// - public string CreateAndRenderUrl(CreateUrlImageRequest request, RenderImageFormat format = RenderImageFormat.PNG) - { - char[]? rented = null; - Span chars = stackalloc char[512]; - var curr_char = 0; - QueryStringEncoder.Encode("url", request.Url, ref curr_char, ref chars, ref rented); - if (!string.IsNullOrWhiteSpace(request.Css)) - { - QueryStringEncoder.Encode("css", request.Css, ref curr_char, ref chars, ref rented); - } - - if (!string.IsNullOrWhiteSpace(request.Selector)) - { - QueryStringEncoder.Encode("selector", request.Selector, ref curr_char, ref chars, ref rented); - } - - if (!string.IsNullOrWhiteSpace(request.Timezone)) - { - QueryStringEncoder.Encode("timezone", request.Timezone, ref curr_char, ref chars, ref rented); - } - - if (request.FullScreen == true) - { - QueryStringEncoder.Encode("full_screen", "true", ref curr_char, ref chars, ref rented); - } - - if (request.BlockConsentBanners == true) - { - QueryStringEncoder.Encode("block_consent_banners", "true", ref curr_char, ref chars, ref rented); - } - - if (request.DisableTwemoji == true) - { - QueryStringEncoder.Encode("disable_twemoji", "true", ref curr_char, ref chars, ref rented); - } - - if (request.MaxRenderOnce == true) - { - QueryStringEncoder.Encode("max_render_once", "true", ref curr_char, ref chars, ref rented); - } - - if (request.RenderWhenReady == true) - { - QueryStringEncoder.Encode("render_when_ready", "true", ref curr_char, ref chars, ref rented); - } - - if (request.ColorScheme != null) - { - QueryStringEncoder.Encode("color_scheme", request.ColorScheme.Value.ToString(), ref curr_char, ref chars, ref rented); - } - - if (request.DeviceScale != null) - { - QueryStringEncoder.Write("device_scale", request.DeviceScale.Value, ref curr_char, ref chars, ref rented); - } - - if (request.MaxWaitMs != null) - { - QueryStringEncoder.Write("max_wait_ms", request.MaxWaitMs.Value, ref curr_char, ref chars, ref rented); - } - - if (request.MsDelay != null) - { - QueryStringEncoder.Write("ms_delay", request.MsDelay.Value, ref curr_char, ref chars, ref rented); - } - - if (request.ViewportHeight != null) - { - QueryStringEncoder.Write("viewport_height", request.ViewportHeight.Value, ref curr_char, ref chars, ref rented); - } - - if (request.ViewportWidth != null) - { - QueryStringEncoder.Write("viewport_width", request.ViewportWidth.Value, ref curr_char, ref chars, ref rented); - } - - var token = HmacToken.CreateToken(chars[..curr_char], _apiKey); - - var format_string = string.Empty; - if (format != RenderImageFormat.PNG) - { - format_string = $"/{format.RenderFormatToExtensionWithoutDot()}"; - } - - var url = $"{CREATE_AND_RENDER_PATH}/{_apiId}/{token}{format_string}?{chars[..curr_char]}"; - - if (rented != null) - { - ArrayPool.Shared.Return(rented); - } - - return url; - } -} \ No newline at end of file diff --git a/src/HtmlCssToImage/IHtmlCssToImageClient.cs b/src/HtmlCssToImage/IHtmlCssToImageClient.cs index ac8f23e..51f6bd4 100644 --- a/src/HtmlCssToImage/IHtmlCssToImageClient.cs +++ b/src/HtmlCssToImage/IHtmlCssToImageClient.cs @@ -115,4 +115,45 @@ public interface IHtmlCssToImageClient /// Specifies the format of the generated image. Defaults to RenderImageFormat.PNG. /// A string containing the URL of the generated templated image. public string CreateTemplatedImageUrl(string templateId, T templateValues, JsonSerializerOptions jsonSerializerOptions, long? templateVersion = null, RenderImageFormat format = RenderImageFormat.PNG); + + + /// + /// Creates a new version of a template using the specified template ID and request parameters. + /// + /// The unique identifier of the template to create a version for. + /// The object containing the details for the new template version, such as updated configuration or settings. + /// A cancellation token that can be used to cancel the operation before it completes. + /// An object containing the response data for the created template version + /// and its associated metadata, or error details if the operation fails. + public Task> CreateTemplateVersionAsync(string templateId, CreateTemplateRequest request, CancellationToken cancellationToken = default); + + /// + /// Creates a new template based on the provided request and returns the result, including metadata about the created template. + /// + /// The request object containing the parameters for creating a template, such as template properties or layout configurations. + /// A cancellation token that can be used to cancel the operation before completion. + /// An object containing the response with metadata about the newly created template and the status of the operation. + public Task> CreateTemplateAsync(CreateTemplateRequest request, CancellationToken cancellationToken = default); + + + /// + /// Retrieves a paginated list of template versions for a specified template ID. + /// + /// The unique identifier of the template for which versions are to be listed. + /// The maximum number of template versions to include in the response. Defaults to 10. + /// An optional parameter indicating the starting point for the next page of results, if pagination is needed. + /// A cancellation token that can be used to cancel the operation before completion. + /// An object containing a with the retrieved template versions or null if no versions exist. + public Task?>> ListTemplateVersionsAsync(string templateId, int count = 10, long? nextPageStart = null, CancellationToken cancellationToken = default); + + /// + /// Retrieves a paginated list of templates, retrieving the most recent version for each. + /// + /// The maximum number of templates to retrieve in a single page. Defaults to 10 if not specified. + /// An optional parameter specifying the starting point for the next page of results. Null indicates the first page. + /// A cancellation token that can be used to cancel the operation before completion. + /// An containing a object, which holds the returned templates and pagination metadata. + public Task?>> ListTemplatesAsync(int count = 10, long? nextPageStart = null, CancellationToken cancellationToken = default); + + } \ No newline at end of file diff --git a/src/HtmlCssToImage/JsonContext.cs b/src/HtmlCssToImage/JsonContext.cs index 1a437e4..e8deb07 100644 --- a/src/HtmlCssToImage/JsonContext.cs +++ b/src/HtmlCssToImage/JsonContext.cs @@ -35,9 +35,12 @@ namespace HtmlCssToImage; /// - /// - /// - +/// - +/// - +/// - /// [JsonSourceGenerationOptions(UseStringEnumConverter = true, NumberHandling = JsonNumberHandling.AllowReadingFromString, AllowTrailingCommas = true, PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, RespectNullableAnnotations = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - Converters = [typeof(PdfValueWithUnitsNullableConverter),typeof(PdfValueWithUnitsConverter), typeof(PdfMarginsConverter)])] + Converters = [typeof(PdfValueWithUnitsNullableConverter), typeof(PdfValueWithUnitsConverter), typeof(PdfMarginsConverter)])] [JsonSerializable(typeof(CreateHtmlCssImageRequest))] [JsonSerializable(typeof(CreateUrlImageRequest))] [JsonSerializable(typeof(CreateTemplatedImageRequest))] @@ -47,7 +50,7 @@ namespace HtmlCssToImage; [JsonSerializable(typeof(CreateImageResponse))] [JsonSerializable(typeof(ErrorDetails))] [JsonSerializable(typeof(PdfValueWithUnits[]))] -public partial class JsonContext:JsonSerializerContext -{ - -} \ No newline at end of file +[JsonSerializable(typeof(CreateTemplateRequest))] +[JsonSerializable(typeof(CreateTemplateResponse))] +[JsonSerializable(typeof(PaginatedResponse