From 25fcabb549e9579bc4220877dc865877b5f096f7 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Mon, 2 Nov 2026 19:14:16 +0100 Subject: [PATCH] Generated the JSON contracts of the runtime calls at compile time --- .../Services/RustContentStreamJsonContext.cs | 24 +++++ .../Tools/Services/RustJsonContext.cs | 89 +++++++++++++++++++ .../RustPromptInjectionJsonContext.cs | 16 ++++ .../Tools/Services/RustService.Retrieval.cs | 6 +- .../Tools/Services/RustService.Security.cs | 10 ++- .../Tools/Services/RustService.cs | 29 +++++- app/Tests/Serialization/JsonCoverageTests.cs | 2 + .../Serialization/JsonTestRoots.Runtime.cs | 23 +++-- 8 files changed, 177 insertions(+), 22 deletions(-) create mode 100644 app/MindWork AI Studio/Tools/Services/RustContentStreamJsonContext.cs create mode 100644 app/MindWork AI Studio/Tools/Services/RustJsonContext.cs create mode 100644 app/MindWork AI Studio/Tools/Services/RustPromptInjectionJsonContext.cs diff --git a/app/MindWork AI Studio/Tools/Services/RustContentStreamJsonContext.cs b/app/MindWork AI Studio/Tools/Services/RustContentStreamJsonContext.cs new file mode 100644 index 000000000..449593c85 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Services/RustContentStreamJsonContext.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace AIStudio.Tools.Services; + +/// +/// The contracts with which System.Text.Json reads the events of a file extraction from the runtime, built at +/// compile time. +/// +/// +/// A converter reads the metadata of an event as the type its JSON tells, so each of these types needs a +/// contract of its own here. A test fails for one which is missing. +/// +[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(ContentStreamSseEvent))] +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(ContentStreamTextMetadata))] +[JsonSerializable(typeof(ContentStreamPdfMetadata))] +[JsonSerializable(typeof(ContentStreamSpreadsheetMetadata))] +[JsonSerializable(typeof(ContentStreamPresentationMetadata))] +[JsonSerializable(typeof(ContentStreamImageMetadata))] +[JsonSerializable(typeof(ContentStreamDocumentMetadata))] +[JsonSerializable(typeof(ContentStreamErrorMetadata))] +[JsonSerializable(typeof(ContentStreamPromptInjectionMetadata))] +internal sealed partial class RustContentStreamJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/RustJsonContext.cs b/app/MindWork AI Studio/Tools/Services/RustJsonContext.cs new file mode 100644 index 000000000..c0ec6ae74 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Services/RustJsonContext.cs @@ -0,0 +1,89 @@ +using System.Text.Json.Serialization; + +using AIStudio.Tools.Databases.VectorStore; +using AIStudio.Tools.Rust; + +namespace AIStudio.Tools.Services; + +/// +/// The contracts with which System.Text.Json reads and writes nearly every call to the runtime, built at +/// compile time. +/// +/// +/// The calls to the vector store are generic: every query of its client needs the answer type with the type +/// of its result, e.g., DatabaseQueryResponse<bool?>. A test fails for a request or an answer which is +/// missing here. +/// +[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] + +// The requests: +[JsonSerializable(typeof(SelectSecretRequest))] +[JsonSerializable(typeof(StoreSecretRequest))] +[JsonSerializable(typeof(RustService.ShareFileRequest))] +[JsonSerializable(typeof(PreviousDirectory))] +[JsonSerializable(typeof(SelectFileOptions))] +[JsonSerializable(typeof(SaveFileOptions))] +[JsonSerializable(typeof(OpenPathRequest))] +[JsonSerializable(typeof(OpenDocumentRequest))] +[JsonSerializable(typeof(RegisterShortcutRequest))] +[JsonSerializable(typeof(ValidateShortcutRequest))] +[JsonSerializable(typeof(SetInterfaceSizeRequest))] +[JsonSerializable(typeof(CreateMediaJobRequest))] +[JsonSerializable(typeof(LogEventRequest))] +[JsonSerializable(typeof(ValidateTokenizerRequest))] +[JsonSerializable(typeof(StoreTokenizerRequest))] +[JsonSerializable(typeof(DeleteTokenizerRequest))] +[JsonSerializable(typeof(CountTokensRequest))] +[JsonSerializable(typeof(PrepareImageRequest))] +[JsonSerializable(typeof(PrepareImageForModelRequest))] + +// The answers: +[JsonSerializable(typeof(RequestedSecret))] +[JsonSerializable(typeof(StoreSecretResponse))] +[JsonSerializable(typeof(DeleteSecretResponse))] +[JsonSerializable(typeof(ShareFileResponse))] +[JsonSerializable(typeof(ImagePrepareResponse))] +[JsonSerializable(typeof(PreparedModelImage))] +[JsonSerializable(typeof(TauriEvent))] +[JsonSerializable(typeof(UpdateResponse))] +[JsonSerializable(typeof(SetClipboardResponse))] +[JsonSerializable(typeof(DirectorySelectionResponse))] +[JsonSerializable(typeof(FileSelectionResponse))] +[JsonSerializable(typeof(FilesSelectionResponse))] +[JsonSerializable(typeof(FileSaveResponse))] +[JsonSerializable(typeof(OpenPathResponse))] +[JsonSerializable(typeof(OpenDocumentResponse))] +[JsonSerializable(typeof(ShortcutRegistrationResult))] +[JsonSerializable(typeof(ShortcutValidationResponse))] +[JsonSerializable(typeof(ShortcutResponse))] +[JsonSerializable(typeof(RuntimeInfoResponse))] +[JsonSerializable(typeof(AppExitResponse))] +[JsonSerializable(typeof(AppRestartResponse))] +[JsonSerializable(typeof(InterfaceSizeResponse))] +[JsonSerializable(typeof(CreateMediaJobResponse))] +[JsonSerializable(typeof(MediaJobEvent))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(GetLogPathsResponse))] +[JsonSerializable(typeof(TokenizerResponse))] + +// The vector store, with the result of every query its client sends: +[JsonSerializable(typeof(QdrantEdgeInfo))] +[JsonSerializable(typeof(QdrantEdgeClientImplementation.EnsureVectorStoreRequest))] +[JsonSerializable(typeof(QdrantEdgeClientImplementation.CheckVectorStoreRequest))] +[JsonSerializable(typeof(QdrantEdgeClientImplementation.InsertEmbeddingRequest))] +[JsonSerializable(typeof(QdrantEdgeClientImplementation.SearchEmbeddingRequest))] +[JsonSerializable(typeof(QdrantEdgeClientImplementation.RetrieveVectorsRequest))] +[JsonSerializable(typeof(QdrantEdgeClientImplementation.DeleteEmbeddingByFileRequest))] +[JsonSerializable(typeof(QdrantEdgeClientImplementation.RelocateEmbeddingFileRequest))] +[JsonSerializable(typeof(QdrantEdgeClientImplementation.OptimizeVectorStoreRequest))] +[JsonSerializable(typeof(QdrantEdgeClientImplementation.DeleteVectorStoreRequest))] +[JsonSerializable(typeof(QdrantEdgeClientImplementation.PrepareBackupRequest))] +[JsonSerializable(typeof(QdrantEdgeClientImplementation.CopyStoreForBackupRequest))] +[JsonSerializable(typeof(RustService.DatabaseOperationResponse))] +[JsonSerializable(typeof(RustService.DatabaseQueryResponse))] +[JsonSerializable(typeof(RustService.DatabaseQueryResponse))] +[JsonSerializable(typeof(RustService.DatabaseQueryResponse>))] +[JsonSerializable(typeof(RustService.DatabaseQueryResponse>))] +[JsonSerializable(typeof(RustService.DatabaseQueryResponse))] +[JsonSerializable(typeof(RustService.DatabaseQueryResponse))] +internal sealed partial class RustJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/RustPromptInjectionJsonContext.cs b/app/MindWork AI Studio/Tools/Services/RustPromptInjectionJsonContext.cs new file mode 100644 index 000000000..4e0a74a4b --- /dev/null +++ b/app/MindWork AI Studio/Tools/Services/RustPromptInjectionJsonContext.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +using AIStudio.Tools.Rust; + +namespace AIStudio.Tools.Services; + +/// +/// The contracts with which System.Text.Json reads and writes the calls to the prompt injection filter of the +/// runtime, built at compile time. +/// +[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(SanitizePromptInjectionsRequest))] +[JsonSerializable(typeof(SanitizePromptInjectionsResponse))] +[JsonSerializable(typeof(SanitizePromptInjectionsBatchRequest))] +[JsonSerializable(typeof(SanitizePromptInjectionsBatchResponse))] +internal sealed partial class RustPromptInjectionJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Retrieval.cs b/app/MindWork AI Studio/Tools/Services/RustService.Retrieval.cs index 220a72105..0954589f0 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Retrieval.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Retrieval.cs @@ -112,7 +112,7 @@ public sealed partial class RustService try { - var sseEvent = JsonSerializer.Deserialize(jsonContent); + var sseEvent = JsonSerializer.Deserialize(jsonContent, CONTENT_STREAM_JSON_OPTIONS); if (sseEvent is null) continue; @@ -364,7 +364,7 @@ public sealed partial class RustService ContentStreamSseEvent? sseEvent = null; try { - sseEvent = JsonSerializer.Deserialize(jsonContent); + sseEvent = JsonSerializer.Deserialize(jsonContent, CONTENT_STREAM_JSON_OPTIONS); } catch (JsonException) { @@ -460,7 +460,7 @@ public sealed partial class RustService { try { - var errorMessage = JsonSerializer.Deserialize(jsonContent); + var errorMessage = JsonSerializer.Deserialize(jsonContent, CONTENT_STREAM_JSON_OPTIONS); if (string.IsNullOrWhiteSpace(errorMessage)) return false; diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Security.cs b/app/MindWork AI Studio/Tools/Services/RustService.Security.cs index 07b829aed..ac831ef76 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Security.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Security.cs @@ -40,7 +40,8 @@ public sealed partial class RustService using var response = await this.http.PostAsJsonAsync( "/security/prompt-injection/sanitize", new SanitizePromptInjectionsRequest(text), - cancellationToken: timeoutTokenSource.Token); + PROMPT_INJECTION_JSON_OPTIONS, + timeoutTokenSource.Token); if (!response.IsSuccessStatusCode) { @@ -48,7 +49,7 @@ public sealed partial class RustService return null; } - return await response.Content.ReadFromJsonAsync(timeoutTokenSource.Token); + return await response.Content.ReadFromJsonAsync(PROMPT_INJECTION_JSON_OPTIONS, timeoutTokenSource.Token); } catch (Exception exception) { @@ -82,7 +83,8 @@ public sealed partial class RustService using var response = await this.http.PostAsJsonAsync( "/security/prompt-injection/sanitize-batch", new SanitizePromptInjectionsBatchRequest(texts), - cancellationToken: timeoutTokenSource.Token); + PROMPT_INJECTION_JSON_OPTIONS, + timeoutTokenSource.Token); if (!response.IsSuccessStatusCode) { @@ -90,7 +92,7 @@ public sealed partial class RustService return null; } - var batchResponse = await response.Content.ReadFromJsonAsync(timeoutTokenSource.Token); + var batchResponse = await response.Content.ReadFromJsonAsync(PROMPT_INJECTION_JSON_OPTIONS, timeoutTokenSource.Token); if (batchResponse.Results is null || batchResponse.Results.Count != texts.Count) { this.logger?.LogError("The prompt injection filter answered with {ResultCount} result(s) for {TextCount} text(s).", batchResponse.Results?.Count ?? 0, texts.Count); diff --git a/app/MindWork AI Studio/Tools/Services/RustService.cs b/app/MindWork AI Studio/Tools/Services/RustService.cs index 8fb76002c..8e849379a 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.cs @@ -37,15 +37,38 @@ public sealed partial class RustService : BackgroundService /// /// The JSON options of nearly every call to the runtime, which names its fields in snake case. /// - internal static readonly JsonSerializerOptions JSON_OPTIONS = new() + internal static readonly JsonSerializerOptions JSON_OPTIONS = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, Converters = { new RustEnumConverter(), }, - }; - + TypeInfoResolver = RustJsonContext.Default, + }.AsReadOnly(); + + /// + /// The JSON options of the events of a file extraction, which name their fields themselves. + /// + /// + /// The same as the defaults of System.Text.Json, with which these events were read before. + /// + internal static readonly JsonSerializerOptions CONTENT_STREAM_JSON_OPTIONS = new JsonSerializerOptions + { + TypeInfoResolver = RustContentStreamJsonContext.Default, + }.AsReadOnly(); + + /// + /// The JSON options of the prompt injection filter, which names its fields in camel case. + /// + /// + /// The same as the defaults of the JSON calls of HttpClient, with which the filter was called before. + /// + internal static readonly JsonSerializerOptions PROMPT_INJECTION_JSON_OPTIONS = new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + TypeInfoResolver = RustPromptInjectionJsonContext.Default, + }.AsReadOnly(); + private ILogger? logger; private Encryption? encryptor; private string? cachedUserLanguage; diff --git a/app/Tests/Serialization/JsonCoverageTests.cs b/app/Tests/Serialization/JsonCoverageTests.cs index c6329a0ab..e40776b90 100644 --- a/app/Tests/Serialization/JsonCoverageTests.cs +++ b/app/Tests/Serialization/JsonCoverageTests.cs @@ -16,6 +16,8 @@ public sealed class JsonCoverageTests [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.Stored))] [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.ProviderRequests))] [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.ProviderAnswers))] + [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.RuntimeRequests))] + [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.RuntimeAnswers))] public void EveryContractComesFromSourceGeneration(JsonTestRoot root) { var typesWithoutGeneratedContract = JsonTypeGraph.Collect(root.Type, root.Options) diff --git a/app/Tests/Serialization/JsonTestRoots.Runtime.cs b/app/Tests/Serialization/JsonTestRoots.Runtime.cs index b0d827989..8cc355fa5 100644 --- a/app/Tests/Serialization/JsonTestRoots.Runtime.cs +++ b/app/Tests/Serialization/JsonTestRoots.Runtime.cs @@ -1,5 +1,4 @@ using System.Reflection; -using System.Text.Json; using AIStudio.Tools; using AIStudio.Tools.Databases.VectorStore; @@ -42,9 +41,9 @@ public static partial class JsonTestRoots foreach (var type in typeof(QdrantEdgeClientImplementation).GetNestedTypes(BindingFlags.NonPublic).Where(type => type.Name.EndsWith("Request", StringComparison.Ordinal)).OrderBy(type => type.Name, StringComparer.Ordinal)) yield return Case(new(type, options)); - // The prompt injection filter, which gets called with the defaults of HttpClient, i.e., with camel case: - yield return Case(new(typeof(SanitizePromptInjectionsRequest), JsonSerializerOptions.Web)); - yield return Case(new(typeof(SanitizePromptInjectionsBatchRequest), JsonSerializerOptions.Web)); + // The prompt injection filter, which names its fields in camel case: + yield return Case(new(typeof(SanitizePromptInjectionsRequest), RustService.PROMPT_INJECTION_JSON_OPTIONS)); + yield return Case(new(typeof(SanitizePromptInjectionsBatchRequest), RustService.PROMPT_INJECTION_JSON_OPTIONS)); } /// @@ -94,16 +93,16 @@ public static partial class JsonTestRoots yield return Case(new(typeof(RustService.DatabaseQueryResponse), options)); // - // The events of a file extraction, which get read with the defaults of System.Text.Json. A converter reads - // their metadata as the type the JSON tells: + // The events of a file extraction, which name their fields themselves. A converter reads their metadata as + // the type the JSON tells: // - yield return Case(new(typeof(ContentStreamSseEvent), JsonSerializerOptions.Default)); - yield return Case(new(typeof(string), JsonSerializerOptions.Default), "error of a file extraction"); + yield return Case(new(typeof(ContentStreamSseEvent), RustService.CONTENT_STREAM_JSON_OPTIONS)); + yield return Case(new(typeof(string), RustService.CONTENT_STREAM_JSON_OPTIONS), "error of a file extraction"); foreach (var type in ImplementationsOf(typeof(ContentStreamSseMetadata))) - yield return Case(new(type, JsonSerializerOptions.Default)); + yield return Case(new(type, RustService.CONTENT_STREAM_JSON_OPTIONS)); - // The prompt injection filter, which gets called with the defaults of HttpClient, i.e., with camel case: - yield return Case(new(typeof(SanitizePromptInjectionsResponse), JsonSerializerOptions.Web)); - yield return Case(new(typeof(SanitizePromptInjectionsBatchResponse), JsonSerializerOptions.Web)); + // The prompt injection filter, which names its fields in camel case: + yield return Case(new(typeof(SanitizePromptInjectionsResponse), RustService.PROMPT_INJECTION_JSON_OPTIONS)); + yield return Case(new(typeof(SanitizePromptInjectionsBatchResponse), RustService.PROMPT_INJECTION_JSON_OPTIONS)); } } \ No newline at end of file