From 7f05ef7c9354ff18c9caa1d01e241b5bd72712fe Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 10:38:37 +0100 Subject: [PATCH 1/7] Add OpenApiMockResponsePlugin to mock APIs from OpenAPI specs. Closes #531 Generates responses from spec examples, the configured language model, or schema-based fallback values, and caches them per operation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Mocking/OpenApiMockResponsePlugin.cs | 395 ++++++++++++++++++ .../Mocking/OpenApiMockResponseSpecLoader.cs | 25 ++ .../Mocking/OpenApiSchemaSampleGenerator.cs | 382 +++++++++++++++++ .../prompts/openapi_mock_response.prompty | 35 ++ .../openapimockresponseplugin.schema.json | 19 + 5 files changed, 856 insertions(+) create mode 100644 DevProxy.Plugins/Mocking/OpenApiMockResponsePlugin.cs create mode 100644 DevProxy.Plugins/Mocking/OpenApiMockResponseSpecLoader.cs create mode 100644 DevProxy.Plugins/Mocking/OpenApiSchemaSampleGenerator.cs create mode 100644 DevProxy/prompts/openapi_mock_response.prompty create mode 100644 schemas/v4.1.0/openapimockresponseplugin.schema.json diff --git a/DevProxy.Plugins/Mocking/OpenApiMockResponsePlugin.cs b/DevProxy.Plugins/Mocking/OpenApiMockResponsePlugin.cs new file mode 100644 index 000000000..7dfef7570 --- /dev/null +++ b/DevProxy.Plugins/Mocking/OpenApiMockResponsePlugin.cs @@ -0,0 +1,395 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Abstractions.LanguageModel; +using DevProxy.Abstractions.Models; +using DevProxy.Abstractions.Plugins; +using DevProxy.Abstractions.Proxy; +using DevProxy.Abstractions.Proxy.Http; +using DevProxy.Abstractions.Utils; +using DevProxy.Plugins.Behavior; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.OpenApi; +using Microsoft.OpenApi.Reader; +using System.Collections.Concurrent; +using System.Globalization; +using System.Net; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace DevProxy.Plugins.Mocking; + +public sealed class OpenApiMockResponsePluginConfiguration +{ + public string? SpecFile { get; set; } +} + +public sealed class OpenApiMockResponsePlugin( + HttpClient httpClient, + ILogger logger, + ISet urlsToWatch, + ILanguageModelClient languageModelClient, + IProxyConfiguration proxyConfiguration, + IConfigurationSection pluginConfigurationSection) : + BasePlugin( + httpClient, + logger, + urlsToWatch, + proxyConfiguration, + pluginConfigurationSection) +{ + private const string PromptFileName = "openapi_mock_response"; + +#pragma warning disable CA2213 // HttpClient is injected, so we don't dispose it + private readonly HttpClient _httpClient = httpClient; +#pragma warning restore CA2213 + private readonly ILanguageModelClient _languageModelClient = languageModelClient; + // generated response bodies cached per operation (method + path template) + private readonly ConcurrentDictionary>> _responseCache = new(StringComparer.OrdinalIgnoreCase); + + private volatile OpenApiDocument? _document; + private OpenApiMockResponseSpecLoader? _loader; + + public override string Name => nameof(OpenApiMockResponsePlugin); + + public override async Task InitializeAsync(InitArgs e, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(e); + + await base.InitializeAsync(e, cancellationToken); + + if (string.IsNullOrWhiteSpace(Configuration.SpecFile)) + { + Logger.LogError("No OpenAPI spec file specified. Set the specFile property in the {Plugin} configuration. The plugin will be disabled.", Name); + Enabled = false; + return; + } + + if (IsRemoteSpec(Configuration.SpecFile)) + { + await LoadRemoteSpecAsync(Configuration.SpecFile, cancellationToken); + } + else + { + Configuration.SpecFile = ProxyUtils.GetFullPath(Configuration.SpecFile, ProxyConfiguration.ConfigFile); + _loader = ActivatorUtilities.CreateInstance( + e.ServiceProvider, + Configuration, + (Action)LoadSpec); + await _loader.InitFileWatcherAsync(cancellationToken); + } + } + + public override async Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken cancellationToken) + { + Logger.LogTrace("{Method} called", nameof(BeforeRequestAsync)); + + ArgumentNullException.ThrowIfNull(e); + + if (!e.ShouldExecute(UrlsToWatch)) + { + Logger.LogRequest("URL not matched", MessageType.Skipped, new LoggingContext(e.ProxySession)); + return; + } + + var document = _document; + if (document is null) + { + Logger.LogRequest("OpenAPI spec not loaded", MessageType.Skipped, new LoggingContext(e.ProxySession)); + return; + } + + var request = e.ProxySession.Request; + var pathItem = document.FindMatchingPathItem(request.RequestUri.AbsoluteUri, Logger); + if (pathItem is null) + { + Logger.LogRequest("No matching path in the OpenAPI spec", MessageType.Skipped, new LoggingContext(e.ProxySession)); + return; + } + + var (pathTemplate, pathItemValue) = pathItem.Value; + var method = new HttpMethod(request.Method); + if (pathItemValue.Operations?.TryGetValue(method, out var operation) != true || operation is null) + { + Logger.LogRequest("No matching operation in the OpenAPI spec", MessageType.Skipped, new LoggingContext(e.ProxySession)); + return; + } + + var (statusCode, response) = GetMockResponse(operation); + var (contentType, mediaType) = GetMediaType(response); + + string? body = null; + if (mediaType is not null && statusCode != HttpStatusCode.NoContent) + { + var cacheKey = $"{method.Method} {pathTemplate}"; + var lazyBody = _responseCache.GetOrAdd(cacheKey, _ => new(() => + // don't tie the cached generation to a single request's lifetime + GenerateBodyAsync(method.Method, pathTemplate, operation, contentType!, mediaType, CancellationToken.None))); + body = await lazyBody.Value; + } + + var headers = new List(); + if (body is not null && contentType is not null) + { + headers.Add(new("Content-Type", contentType)); + } + if (request.Headers.Any(h => h.Name.Equals("Origin", StringComparison.OrdinalIgnoreCase))) + { + headers.Add(new("Access-Control-Allow-Origin", "*")); + } + if (e.SessionData.TryGetValue(nameof(RateLimitingPlugin), out var pluginData) && + pluginData is List rateLimitingHeaders) + { + ProxyUtils.MergeHeaders(headers, rateLimitingHeaders); + } + + e.ProxySession.Respond(body ?? string.Empty, statusCode, headers.Select(h => new HttpHeader(h.Name, h.Value))); + e.ResponseState.HasBeenSet = true; + + Logger.LogRequest($"{(int)statusCode} {pathTemplate}", MessageType.Mocked, new LoggingContext(e.ProxySession)); + + Logger.LogTrace("Left {Name}", nameof(BeforeRequestAsync)); + } + + private static bool IsRemoteSpec(string specFile) => + Uri.TryCreate(specFile, UriKind.Absolute, out var uri) && + (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps); + + private async Task LoadRemoteSpecAsync(string specUrl, CancellationToken cancellationToken) + { + try + { + Logger.LogDebug("Downloading OpenAPI spec from {SpecUrl}...", specUrl); + var contents = await _httpClient.GetStringAsync(new Uri(specUrl), cancellationToken); + LoadSpec(contents); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to download OpenAPI spec from {SpecUrl}", specUrl); + } + } + + private void LoadSpec(string contents) + { + try + { + contents = ProxyUtils.ReplaceVariables(contents, ProxyConfiguration.Env, v => $"{{{v}}}"); + + var settings = new OpenApiReaderSettings(); + settings.AddYamlReader(); + var readResult = OpenApiDocument.Parse(contents, null, settings); + var document = readResult.Document; + if (document is null) + { + Logger.LogError("Failed to parse OpenAPI spec {SpecFile}", Configuration.SpecFile); + return; + } + + if (document.Servers is null || document.Servers.Count == 0) + { + Logger.LogWarning("OpenAPI spec {SpecFile} doesn't define any servers. Requests can't be matched to its operations", Configuration.SpecFile); + } + + _document = document; + // the spec changed, so previously generated responses might be invalid + _responseCache.Clear(); + + Logger.LogInformation("Loaded OpenAPI spec {SpecFile} with {PathCount} paths", Configuration.SpecFile, document.Paths?.Count ?? 0); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to load OpenAPI spec {SpecFile}", Configuration.SpecFile); + } + } + + private static (HttpStatusCode StatusCode, IOpenApiResponse? Response) GetMockResponse(OpenApiOperation operation) + { + var responses = operation.Responses; + if (responses is null || responses.Count == 0) + { + return (HttpStatusCode.OK, null); + } + + var successResponse = responses + .Where(r => r.Key.StartsWith('2')) + .OrderBy(r => r.Key, StringComparer.Ordinal) + .FirstOrDefault(); + if (successResponse.Value is not null) + { + var statusCode = int.TryParse(successResponse.Key, NumberStyles.None, CultureInfo.InvariantCulture, out var code) ? code : 200; + return ((HttpStatusCode)statusCode, successResponse.Value); + } + + return responses.TryGetValue("default", out var defaultResponse) ? + (HttpStatusCode.OK, defaultResponse) : + (HttpStatusCode.OK, null); + } + + private static (string? ContentType, IOpenApiMediaType? MediaType) GetMediaType(IOpenApiResponse? response) + { + if (response?.Content is null || response.Content.Count == 0) + { + return (null, null); + } + + var media = response.Content.FirstOrDefault(c => IsJsonContentType(c.Key)); + if (media.Value is null) + { + media = response.Content.First(); + } + + // wildcard content types aren't valid response content types + var contentType = media.Key.Contains('*', StringComparison.Ordinal) ? "application/json" : media.Key; + return (contentType, media.Value); + } + + private static bool IsJsonContentType(string contentType) => + contentType.Contains("json", StringComparison.OrdinalIgnoreCase) || + contentType.Contains('*', StringComparison.Ordinal); + + private async Task GenerateBodyAsync(string method, string pathTemplate, OpenApiOperation operation, string contentType, IOpenApiMediaType mediaType, CancellationToken cancellationToken) + { + var isJson = IsJsonContentType(contentType); + + var example = GetExample(mediaType); + if (example is not null) + { + Logger.LogDebug("Using example from the OpenAPI spec for {Method} {Path}", method, pathTemplate); + return Serialize(example, isJson); + } + + if (mediaType.Schema is null) + { + return null; + } + + if (isJson) + { + var generated = await GenerateWithLanguageModelAsync(method, pathTemplate, operation, mediaType.Schema, cancellationToken); + if (generated is not null) + { + return generated; + } + } + + Logger.LogDebug("Generating response for {Method} {Path} from schema", method, pathTemplate); + var sample = OpenApiSchemaSampleGenerator.GenerateSample(mediaType.Schema); + return sample is null ? null : Serialize(sample, isJson); + } + + private static JsonNode? GetExample(IOpenApiMediaType mediaType) + { + if (mediaType.Example is not null) + { + return mediaType.Example; + } + + return mediaType.Examples?.Values + .Select(e => e?.Value) + .FirstOrDefault(v => v is not null); + } + + private static string Serialize(JsonNode node, bool isJson) + { + if (!isJson && node is JsonValue value && value.TryGetValue(out var stringValue)) + { + return stringValue; + } + + return node.ToJsonString(ProxyUtils.JsonSerializerOptions); + } + + private async Task GenerateWithLanguageModelAsync(string method, string pathTemplate, OpenApiOperation operation, IOpenApiSchema schema, CancellationToken cancellationToken) + { + try + { + if (!await _languageModelClient.IsEnabledAsync(cancellationToken)) + { + return null; + } + + Logger.LogInformation("Generating response for {Method} {Path} using language model...", method, pathTemplate); + + var schemaDescription = OpenApiSchemaSampleGenerator.Describe(schema); + var response = await _languageModelClient.GenerateChatCompletionAsync(PromptFileName, new() + { + { "request", $"{method} {pathTemplate}" }, + { "description", string.Join(" ", new[] { operation.Summary, operation.Description }.Where(s => !string.IsNullOrWhiteSpace(s))) }, + { "schema", schemaDescription.ToJsonString(ProxyUtils.JsonSerializerOptions) } + }, cancellationToken); + + var expectedType = schemaDescription["type"]?.GetValue(); + var json = NormalizeToExpectedType(ExtractJson(response?.Response), expectedType); + if (json is null) + { + Logger.LogWarning("Language model didn't return valid JSON matching the schema for {Method} {Path}. Generating response from schema", method, pathTemplate); + Logger.LogDebug("Language model response: {Response}", response?.Response); + return null; + } + + return json.ToJsonString(ProxyUtils.JsonSerializerOptions); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to generate response for {Method} {Path} using language model. Generating response from schema", method, pathTemplate); + return null; + } + } + + private static JsonNode? NormalizeToExpectedType(JsonNode? json, string? expectedType) + { + switch (expectedType) + { + case "array" when json is JsonObject obj: + // small models often wrap the array in an object, eg. { "users": [...] }, + // or return a single item instead of an array + var arrays = obj.Where(p => p.Value is JsonArray).ToArray(); + if (arrays.Length == 1 && obj.Count == 1) + { + return arrays[0].Value!.DeepClone(); + } + return new JsonArray(obj.DeepClone()); + case "array" when json is not JsonArray: + case "object" when json is not JsonObject: + return null; + default: + return json; + } + } + + private static JsonNode? ExtractJson(string? response) + { + if (string.IsNullOrWhiteSpace(response)) + { + return null; + } + + var start = response.IndexOfAny(['{', '[']); + var end = response.LastIndexOfAny(['}', ']']); + if (start < 0 || end <= start) + { + return null; + } + + try + { + return JsonNode.Parse(response[start..(end + 1)]); + } + catch (JsonException) + { + return null; + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _loader?.Dispose(); + } + base.Dispose(disposing); + } +} diff --git a/DevProxy.Plugins/Mocking/OpenApiMockResponseSpecLoader.cs b/DevProxy.Plugins/Mocking/OpenApiMockResponseSpecLoader.cs new file mode 100644 index 000000000..8cf36278d --- /dev/null +++ b/DevProxy.Plugins/Mocking/OpenApiMockResponseSpecLoader.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Abstractions.Plugins; +using DevProxy.Abstractions.Proxy; +using Microsoft.Extensions.Logging; + +namespace DevProxy.Plugins.Mocking; + +internal sealed class OpenApiMockResponseSpecLoader( + HttpClient httpClient, + ILogger logger, + OpenApiMockResponsePluginConfiguration configuration, + Action onSpecLoaded, + IProxyConfiguration proxyConfiguration) : + BaseLoader(httpClient, logger, proxyConfiguration) +{ + private readonly OpenApiMockResponsePluginConfiguration _configuration = configuration; + private readonly Action _onSpecLoaded = onSpecLoaded; + + protected override string FilePath => _configuration.SpecFile!; + + protected override void LoadData(string fileContents) => _onSpecLoaded(fileContents); +} diff --git a/DevProxy.Plugins/Mocking/OpenApiSchemaSampleGenerator.cs b/DevProxy.Plugins/Mocking/OpenApiSchemaSampleGenerator.cs new file mode 100644 index 000000000..432481bca --- /dev/null +++ b/DevProxy.Plugins/Mocking/OpenApiSchemaSampleGenerator.cs @@ -0,0 +1,382 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.OpenApi; +using System.Globalization; +using System.Text.Json.Nodes; + +namespace DevProxy.Plugins.Mocking; + +/// +/// Generates sample values and simplified, reference-free descriptions +/// of OpenAPI schemas. +/// +internal static class OpenApiSchemaSampleGenerator +{ + private const int MaxDepth = 8; + + public static JsonNode? GenerateSample(IOpenApiSchema? schema) => GenerateSample(schema, 0, []); + + /// + /// Returns a simplified JSON Schema-like representation of the schema with + /// all references resolved, suitable to pass to a language model. + /// + public static JsonObject Describe(IOpenApiSchema? schema) => Describe(schema, 0, []) ?? []; + + private static string? GetReferenceId(IOpenApiSchema schema) => + schema is OpenApiSchemaReference reference ? reference.Reference?.ReferenceV3 : null; + + private static JsonNode? GenerateSample(IOpenApiSchema? schema, int depth, HashSet visiting) + { + if (schema is null || depth > MaxDepth) + { + return null; + } + + // stop at recursive references to avoid deeply nested samples + var referenceId = GetReferenceId(schema); + if (referenceId is not null && !visiting.Add(referenceId)) + { + return null; + } + + try + { + return GenerateSampleCore(schema, depth, visiting); + } + finally + { + if (referenceId is not null) + { + _ = visiting.Remove(referenceId); + } + } + } + + private static JsonNode? GenerateSampleCore(IOpenApiSchema schema, int depth, HashSet visiting) + { + // Example is obsolete in favor of Examples, but OpenAPI 3.0 specs still use it +#pragma warning disable CS0618 + if (schema.Example is not null) + { + return schema.Example.DeepClone(); + } +#pragma warning restore CS0618 + + var example = schema.Examples?.FirstOrDefault(e => e is not null); + if (example is not null) + { + return example.DeepClone(); + } + + if (schema.Default is not null) + { + return schema.Default.DeepClone(); + } + + var enumValue = schema.Enum?.FirstOrDefault(e => e is not null); + if (enumValue is not null) + { + return enumValue.DeepClone(); + } + + if (schema.AllOf is { Count: > 0 }) + { + return GenerateAllOfSample(schema, depth, visiting); + } + + var alternative = schema.OneOf?.FirstOrDefault() ?? schema.AnyOf?.FirstOrDefault(); + if (alternative is not null) + { + return GenerateSample(alternative, depth + 1, visiting); + } + + return GetEffectiveType(schema) switch + { + JsonSchemaType.Object => GenerateObjectSample(schema, depth, visiting), + JsonSchemaType.Array => GenerateArraySample(schema, depth, visiting), + JsonSchemaType.Integer => JsonValue.Create(GetNumber(schema, 0)), + JsonSchemaType.Number => JsonValue.Create(GetNumber(schema, 0.0m)), + JsonSchemaType.Boolean => JsonValue.Create(true), + JsonSchemaType.Null => null, + _ => JsonValue.Create(GetStringSample(schema)) + }; + } + + private static JsonNode? GenerateAllOfSample(IOpenApiSchema schema, int depth, HashSet visiting) + { + JsonObject? merged = null; + JsonNode? firstNonObject = null; + + foreach (var subSchema in schema.AllOf ?? []) + { + var sample = GenerateSample(subSchema, depth + 1, visiting); + if (sample is JsonObject obj) + { + merged ??= []; + MergeInto(merged, obj); + } + else if (sample is not null) + { + firstNonObject ??= sample; + } + } + + if (schema.Properties is { Count: > 0 }) + { + merged ??= []; + MergeInto(merged, GenerateObjectSample(schema, depth, visiting)); + } + + return merged ?? firstNonObject; + } + + private static JsonObject GenerateObjectSample(IOpenApiSchema schema, int depth, HashSet visiting) + { + var obj = new JsonObject(); + foreach (var (name, propertySchema) in schema.Properties ?? new Dictionary()) + { + if (propertySchema is null || propertySchema.WriteOnly) + { + continue; + } + + obj[name] = GenerateSample(propertySchema, depth + 1, visiting); + } + + if (obj.Count == 0 && schema.AdditionalProperties is not null) + { + obj["additionalProp1"] = GenerateSample(schema.AdditionalProperties, depth + 1, visiting); + } + + return obj; + } + + private static JsonArray GenerateArraySample(IOpenApiSchema schema, int depth, HashSet visiting) + { + var item = GenerateSample(schema.Items, depth + 1, visiting); + return item is null ? [] : [item]; + } + + private static string GetStringSample(IOpenApiSchema schema) + { + var value = schema.Format?.ToLowerInvariant() switch + { + "date-time" => "2024-01-01T12:00:00Z", + "date" => "2024-01-01", + "time" => "12:00:00", + "duration" => "P1D", + "uuid" => "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "email" or "idn-email" => "user@example.com", + "uri" or "url" or "iri" or "uri-reference" or "iri-reference" => "https://example.com", + "hostname" or "idn-hostname" => "example.com", + "ipv4" => "192.168.0.1", + "ipv6" => "2001:db8::1", + "byte" => "c3RyaW5n", + "password" => "P@ssw0rd!", + _ => "string" + }; + + if (schema.MinLength is int minLength && value.Length < minLength) + { + value = value.PadRight(minLength, 'x'); + } + if (schema.MaxLength is int maxLength && value.Length > maxLength) + { + value = value[..maxLength]; + } + + return value; + } + + private static T GetNumber(IOpenApiSchema schema, T fallback) where T : struct + { + if (schema.Minimum is not null && + decimal.TryParse(schema.Minimum.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var minimum)) + { + return (T)Convert.ChangeType(minimum, typeof(T), CultureInfo.InvariantCulture); + } + + return fallback; + } + + private static JsonSchemaType? GetEffectiveType(IOpenApiSchema schema) + { + if (schema.Type is JsonSchemaType type) + { + // pick the first non-null type when multiple types are allowed + foreach (var candidate in new[] + { + JsonSchemaType.Object, JsonSchemaType.Array, JsonSchemaType.String, + JsonSchemaType.Integer, JsonSchemaType.Number, JsonSchemaType.Boolean + }) + { + if (type.HasFlag(candidate)) + { + return candidate; + } + } + + if (type.HasFlag(JsonSchemaType.Null)) + { + return JsonSchemaType.Null; + } + } + + if (schema.Properties is { Count: > 0 } || schema.AdditionalProperties is not null) + { + return JsonSchemaType.Object; + } + + if (schema.Items is not null) + { + return JsonSchemaType.Array; + } + + return null; + } + + private static void MergeInto(JsonObject target, JsonObject source) + { + foreach (var (key, value) in source) + { + target[key] = value?.DeepClone(); + } + } + + private static JsonObject? Describe(IOpenApiSchema? schema, int depth, HashSet visiting) + { + if (schema is null || depth > MaxDepth) + { + return null; + } + + var referenceId = GetReferenceId(schema); + if (referenceId is not null && !visiting.Add(referenceId)) + { + return null; + } + + try + { + return DescribeCore(schema, depth, visiting); + } + finally + { + if (referenceId is not null) + { + _ = visiting.Remove(referenceId); + } + } + } + + private static JsonObject DescribeCore(IOpenApiSchema schema, int depth, HashSet visiting) + { + var description = new JsonObject(); + + var allOfSchemas = schema.AllOf?.Where(s => s is not null).ToArray() ?? []; + if (allOfSchemas.Length > 0) + { + foreach (var subSchema in allOfSchemas) + { + MergeDescription(description, Describe(subSchema, depth + 1, visiting)); + } + } + + var alternative = schema.OneOf?.FirstOrDefault() ?? schema.AnyOf?.FirstOrDefault(); + if (alternative is not null) + { + MergeDescription(description, Describe(alternative, depth + 1, visiting)); + } + + var type = GetEffectiveType(schema); + if (type is not null) + { + description["type"] = type.Value.ToString().ToLowerInvariant(); + } + if (!string.IsNullOrEmpty(schema.Format)) + { + description["format"] = schema.Format; + } + if (!string.IsNullOrEmpty(schema.Description)) + { + description["description"] = schema.Description; + } + if (schema.Minimum is not null) + { + description["minimum"] = ToNumberNode(schema.Minimum.ToString()); + } + if (schema.Maximum is not null) + { + description["maximum"] = ToNumberNode(schema.Maximum.ToString()); + } + if (schema.Enum is { Count: > 0 }) + { + description["enum"] = new JsonArray([.. schema.Enum.Select(e => e?.DeepClone())]); + } +#pragma warning disable CS0618 + if (schema.Example is not null) + { + description["example"] = schema.Example.DeepClone(); + } +#pragma warning restore CS0618 + + if (schema.Properties is { Count: > 0 }) + { + var properties = description["properties"] as JsonObject ?? []; + foreach (var (name, propertySchema) in schema.Properties) + { + if (propertySchema is null || propertySchema.WriteOnly) + { + continue; + } + var propertyDescription = Describe(propertySchema, depth + 1, visiting); + if (propertyDescription is not null) + { + properties[name] = propertyDescription; + } + } + description["properties"] = properties; + } + if (schema.Required is { Count: > 0 }) + { + description["required"] = new JsonArray([.. schema.Required.Select(r => JsonValue.Create(r))]); + } + if (schema.Items is not null) + { + description["items"] = Describe(schema.Items, depth + 1, visiting) ?? []; + } + if (schema.AdditionalProperties is not null) + { + description["additionalProperties"] = Describe(schema.AdditionalProperties, depth + 1, visiting) ?? []; + } + + return description; + } + + private static JsonValue? ToNumberNode(string? value) => + decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var number) ? + JsonValue.Create(number) : + JsonValue.Create(value); + + private static void MergeDescription(JsonObject target, JsonObject? source) + { + if (source is null) + { + return; + } + + foreach (var (key, value) in source) + { + if (key == "properties" && value is JsonObject sourceProperties && + target["properties"] is JsonObject targetProperties) + { + MergeInto(targetProperties, sourceProperties); + continue; + } + + target[key] = value?.DeepClone(); + } + } +} diff --git a/DevProxy/prompts/openapi_mock_response.prompty b/DevProxy/prompts/openapi_mock_response.prompty new file mode 100644 index 000000000..a7e2427e8 --- /dev/null +++ b/DevProxy/prompts/openapi_mock_response.prompty @@ -0,0 +1,35 @@ +--- +name: OpenAPI mock response +description: Generate a realistic mock JSON response for an API operation based on its OpenAPI response schema. +authors: + - Dev Proxy +model: + api: chat + parameters: + temperature: 0.7 +sample: + request: GET /users/{id} + description: Get a user by ID + schema: '{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"givenName":{"type":"string"},"mail":{"type":"string","format":"email"}}}' +--- + +system: +You are an API simulator. Your task is to generate a realistic sample JSON +response body for the API operation provided by the user. Follow these rules: +- The response must be valid JSON that conforms to the provided JSON schema. +- Use realistic values that match the meaning of each property name and its + description, for example real-looking names for `givenName`, email addresses + for `mail`, and plausible dates for date properties. +- Respect property types, formats and enum values defined in the schema. +- Include all properties defined in the schema. +- If the schema type is `array`, respond with a JSON array, not an object + wrapping the array. +- For arrays, include between 2 and 3 items. +- Respond only with the JSON. Don't include explanations, comments or + Markdown code fences. + +user: +Operation: `{{request}}` +Description: {{description}} +Response schema: +{{schema}} diff --git a/schemas/v4.1.0/openapimockresponseplugin.schema.json b/schemas/v4.1.0/openapimockresponseplugin.schema.json new file mode 100644 index 000000000..1a43388ea --- /dev/null +++ b/schemas/v4.1.0/openapimockresponseplugin.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy OpenApiMockResponsePlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "specFile": { + "type": "string", + "description": "Path (relative to the config file) or URL of the OpenAPI spec (JSON or YAML) to mock responses from." + } + }, + "required": [ + "specFile" + ], + "additionalProperties": false +} From 9587d03778e2140b887ea09e245ba707c24ae3c9 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 11:01:47 +0100 Subject: [PATCH 2/7] Address review feedback on OpenApiMockResponsePlugin - Use ordinal cache keys and publish spec + cache as one snapshot - Prefer concrete paths over templated ones when matching requests - Return JSON null for null-only schemas - Accept scalar language model responses and validate scalar types - Keep generated numbers within minimum/maximum without overflow - Don't cache failed generations Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Mocking/OpenApiMockResponsePlugin.cs | 103 +++++++++++++++--- .../Mocking/OpenApiSchemaSampleGenerator.cs | 21 ++-- .../prompts/openapi_mock_response.prompty | 2 + 3 files changed, 106 insertions(+), 20 deletions(-) diff --git a/DevProxy.Plugins/Mocking/OpenApiMockResponsePlugin.cs b/DevProxy.Plugins/Mocking/OpenApiMockResponsePlugin.cs index 7dfef7570..27fa75a1b 100644 --- a/DevProxy.Plugins/Mocking/OpenApiMockResponsePlugin.cs +++ b/DevProxy.Plugins/Mocking/OpenApiMockResponsePlugin.cs @@ -47,10 +47,11 @@ public sealed class OpenApiMockResponsePlugin( private readonly HttpClient _httpClient = httpClient; #pragma warning restore CA2213 private readonly ILanguageModelClient _languageModelClient = languageModelClient; - // generated response bodies cached per operation (method + path template) - private readonly ConcurrentDictionary>> _responseCache = new(StringComparer.OrdinalIgnoreCase); - private volatile OpenApiDocument? _document; + // the spec and the response bodies generated from it, cached per operation + // (method + path template), are published together, so that requests + // handled during a reload can't add stale bodies to the new cache + private volatile SpecSnapshot? _snapshot; private OpenApiMockResponseSpecLoader? _loader; public override string Name => nameof(OpenApiMockResponsePlugin); @@ -95,15 +96,15 @@ public override async Task BeforeRequestAsync(ProxyRequestArgs e, CancellationTo return; } - var document = _document; - if (document is null) + var snapshot = _snapshot; + if (snapshot is null) { Logger.LogRequest("OpenAPI spec not loaded", MessageType.Skipped, new LoggingContext(e.ProxySession)); return; } var request = e.ProxySession.Request; - var pathItem = document.FindMatchingPathItem(request.RequestUri.AbsoluteUri, Logger); + var pathItem = snapshot.Document.FindMatchingPathItem(request.RequestUri.AbsoluteUri, Logger); if (pathItem is null) { Logger.LogRequest("No matching path in the OpenAPI spec", MessageType.Skipped, new LoggingContext(e.ProxySession)); @@ -125,10 +126,20 @@ public override async Task BeforeRequestAsync(ProxyRequestArgs e, CancellationTo if (mediaType is not null && statusCode != HttpStatusCode.NoContent) { var cacheKey = $"{method.Method} {pathTemplate}"; - var lazyBody = _responseCache.GetOrAdd(cacheKey, _ => new(() => + var lazyBody = snapshot.ResponseCache.GetOrAdd(cacheKey, _ => new(() => // don't tie the cached generation to a single request's lifetime GenerateBodyAsync(method.Method, pathTemplate, operation, contentType!, mediaType, CancellationToken.None))); - body = await lazyBody.Value; + try + { + body = await lazyBody.Value; + } + catch (Exception ex) + { + // don't cache failures so that the next request retries + _ = snapshot.ResponseCache.TryRemove(new(cacheKey, lazyBody)); + Logger.LogError(ex, "Failed to generate response for {Method} {Path}", method.Method, pathTemplate); + return; + } } var headers = new List(); @@ -193,9 +204,9 @@ private void LoadSpec(string contents) Logger.LogWarning("OpenAPI spec {SpecFile} doesn't define any servers. Requests can't be matched to its operations", Configuration.SpecFile); } - _document = document; + PrioritizeConcretePaths(document); // the spec changed, so previously generated responses might be invalid - _responseCache.Clear(); + _snapshot = new(document, new(StringComparer.Ordinal)); Logger.LogInformation("Loaded OpenAPI spec {SpecFile} with {PathCount} paths", Configuration.SpecFile, document.Paths?.Count ?? 0); } @@ -205,6 +216,23 @@ private void LoadSpec(string contents) } } + // FindMatchingPathItem returns the first matching path, so put concrete + // paths, eg. /users/me, before templated ones, eg. /users/{id} + private static void PrioritizeConcretePaths(OpenApiDocument document) + { + if (document.Paths is null || document.Paths.Count == 0) + { + return; + } + + var orderedPaths = new OpenApiPaths(); + foreach (var (path, pathItem) in document.Paths.OrderBy(p => p.Key.Count(c => c == '{'))) + { + orderedPaths.Add(path, pathItem); + } + document.Paths = orderedPaths; + } + private static (HttpStatusCode StatusCode, IOpenApiResponse? Response) GetMockResponse(OpenApiOperation operation) { var responses = operation.Responses; @@ -277,7 +305,12 @@ private static bool IsJsonContentType(string contentType) => Logger.LogDebug("Generating response for {Method} {Path} from schema", method, pathTemplate); var sample = OpenApiSchemaSampleGenerator.GenerateSample(mediaType.Schema); - return sample is null ? null : Serialize(sample, isJson); + if (sample is null) + { + // schema allows only null, which is a valid JSON body + return isJson ? "null" : null; + } + return Serialize(sample, isJson); } private static JsonNode? GetExample(IOpenApiMediaType mediaType) @@ -341,6 +374,14 @@ private static string Serialize(JsonNode node, bool isJson) private static JsonNode? NormalizeToExpectedType(JsonNode? json, string? expectedType) { + // small models often wrap scalar values in an object, eg. { "count": 42 } + if (expectedType is "string" or "number" or "integer" or "boolean" && + json is JsonObject { Count: 1 } wrapper && + wrapper.First().Value is JsonValue wrappedValue) + { + json = wrappedValue.DeepClone(); + } + switch (expectedType) { case "array" when json is JsonObject obj: @@ -355,11 +396,20 @@ private static string Serialize(JsonNode node, bool isJson) case "array" when json is not JsonArray: case "object" when json is not JsonObject: return null; + case "string" when GetValueKind(json) != JsonValueKind.String: + case "number" when GetValueKind(json) != JsonValueKind.Number: + case "integer" when GetValueKind(json) != JsonValueKind.Number || + !json!.AsValue().TryGetValue(out var integer) || integer != decimal.Truncate(integer): + case "boolean" when GetValueKind(json) is not (JsonValueKind.True or JsonValueKind.False): + return null; default: return json; } } + private static JsonValueKind? GetValueKind(JsonNode? json) => + json is JsonValue value ? value.GetValueKind() : null; + private static JsonNode? ExtractJson(string? response) { if (string.IsNullOrWhiteSpace(response)) @@ -367,6 +417,22 @@ private static string Serialize(JsonNode node, bool isJson) return null; } + // the response might be a complete JSON value, including scalars, + // optionally wrapped in Markdown code fences + var trimmed = response.Trim(); + if (trimmed.StartsWith("```", StringComparison.Ordinal) && trimmed.EndsWith("```", StringComparison.Ordinal) && trimmed.Length > 6) + { + var firstLineEnd = trimmed.IndexOf('\n', StringComparison.Ordinal); + trimmed = firstLineEnd < 0 ? + trimmed[3..^3].Trim() : + trimmed[(firstLineEnd + 1)..^3].Trim(); + } + if (TryParseJson(trimmed, out var json)) + { + return json; + } + + // or JSON wrapped in other text, like markdown code fences var start = response.IndexOfAny(['{', '[']); var end = response.LastIndexOfAny(['}', ']']); if (start < 0 || end <= start) @@ -374,16 +440,27 @@ private static string Serialize(JsonNode node, bool isJson) return null; } + return TryParseJson(response[start..(end + 1)], out json) ? json : null; + } + + private static bool TryParseJson(string value, out JsonNode? json) + { try { - return JsonNode.Parse(response[start..(end + 1)]); + json = JsonNode.Parse(value); + return json is not null; } catch (JsonException) { - return null; + json = null; + return false; } } + private sealed record SpecSnapshot( + OpenApiDocument Document, + ConcurrentDictionary>> ResponseCache); + protected override void Dispose(bool disposing) { if (disposing) diff --git a/DevProxy.Plugins/Mocking/OpenApiSchemaSampleGenerator.cs b/DevProxy.Plugins/Mocking/OpenApiSchemaSampleGenerator.cs index 432481bca..ef0af6917 100644 --- a/DevProxy.Plugins/Mocking/OpenApiSchemaSampleGenerator.cs +++ b/DevProxy.Plugins/Mocking/OpenApiSchemaSampleGenerator.cs @@ -96,8 +96,8 @@ internal static class OpenApiSchemaSampleGenerator { JsonSchemaType.Object => GenerateObjectSample(schema, depth, visiting), JsonSchemaType.Array => GenerateArraySample(schema, depth, visiting), - JsonSchemaType.Integer => JsonValue.Create(GetNumber(schema, 0)), - JsonSchemaType.Number => JsonValue.Create(GetNumber(schema, 0.0m)), + JsonSchemaType.Integer => JsonValue.Create(GetNumber(schema, isInteger: true)), + JsonSchemaType.Number => JsonValue.Create(GetNumber(schema, isInteger: false)), JsonSchemaType.Boolean => JsonValue.Create(true), JsonSchemaType.Null => null, _ => JsonValue.Create(GetStringSample(schema)) @@ -190,17 +190,24 @@ private static string GetStringSample(IOpenApiSchema schema) return value; } - private static T GetNumber(IOpenApiSchema schema, T fallback) where T : struct + private static decimal GetNumber(IOpenApiSchema schema, bool isInteger) { - if (schema.Minimum is not null && - decimal.TryParse(schema.Minimum.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var minimum)) + // use 0 unless it's outside the allowed range + if (TryParseBound(schema.Minimum?.ToString(), out var minimum) && minimum > 0) { - return (T)Convert.ChangeType(minimum, typeof(T), CultureInfo.InvariantCulture); + return isInteger ? decimal.Ceiling(minimum) : minimum; + } + if (TryParseBound(schema.Maximum?.ToString(), out var maximum) && maximum < 0) + { + return isInteger ? decimal.Floor(maximum) : maximum; } - return fallback; + return 0; } + private static bool TryParseBound(string? bound, out decimal value) => + decimal.TryParse(bound, NumberStyles.Float, CultureInfo.InvariantCulture, out value); + private static JsonSchemaType? GetEffectiveType(IOpenApiSchema schema) { if (schema.Type is JsonSchemaType type) diff --git a/DevProxy/prompts/openapi_mock_response.prompty b/DevProxy/prompts/openapi_mock_response.prompty index a7e2427e8..ada82320d 100644 --- a/DevProxy/prompts/openapi_mock_response.prompty +++ b/DevProxy/prompts/openapi_mock_response.prompty @@ -24,6 +24,8 @@ response body for the API operation provided by the user. Follow these rules: - Include all properties defined in the schema. - If the schema type is `array`, respond with a JSON array, not an object wrapping the array. +- If the schema type is `string`, `number`, `integer` or `boolean`, respond + with only that JSON value, for example `42` or `"Alice"`, not an object. - For arrays, include between 2 and 3 items. - Respond only with the JSON. Don't include explanations, comments or Markdown code fences. From 3b676de1f2d7680809376f1e65dcc7d810b211d8 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 11:10:10 +0100 Subject: [PATCH 3/7] Preserve schema composition constraints in generated samples - Merge enclosing properties with the selected oneOf/anyOf branch - Combine allOf constraints for non-object values - Recursively merge nested objects and required properties Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Mocking/OpenApiSchemaSampleGenerator.cs | 101 ++++++++++++++++-- 1 file changed, 91 insertions(+), 10 deletions(-) diff --git a/DevProxy.Plugins/Mocking/OpenApiSchemaSampleGenerator.cs b/DevProxy.Plugins/Mocking/OpenApiSchemaSampleGenerator.cs index ef0af6917..b7a15595f 100644 --- a/DevProxy.Plugins/Mocking/OpenApiSchemaSampleGenerator.cs +++ b/DevProxy.Plugins/Mocking/OpenApiSchemaSampleGenerator.cs @@ -89,7 +89,18 @@ internal static class OpenApiSchemaSampleGenerator var alternative = schema.OneOf?.FirstOrDefault() ?? schema.AnyOf?.FirstOrDefault(); if (alternative is not null) { - return GenerateSample(alternative, depth + 1, visiting); + var alternativeSample = GenerateSample(alternative, depth + 1, visiting); + // keep the properties defined next to oneOf/anyOf + if (schema.Properties is { Count: > 0 } && alternativeSample is null or JsonObject) + { + var obj = GenerateObjectSample(schema, depth, visiting); + if (alternativeSample is JsonObject alternativeObject) + { + MergeInto(obj, alternativeObject); + } + return obj; + } + return alternativeSample; } return GetEffectiveType(schema) switch @@ -107,20 +118,14 @@ internal static class OpenApiSchemaSampleGenerator private static JsonNode? GenerateAllOfSample(IOpenApiSchema schema, int depth, HashSet visiting) { JsonObject? merged = null; - JsonNode? firstNonObject = null; foreach (var subSchema in schema.AllOf ?? []) { - var sample = GenerateSample(subSchema, depth + 1, visiting); - if (sample is JsonObject obj) + if (GenerateSample(subSchema, depth + 1, visiting) is JsonObject obj) { merged ??= []; MergeInto(merged, obj); } - else if (sample is not null) - { - firstNonObject ??= sample; - } } if (schema.Properties is { Count: > 0 }) @@ -129,7 +134,61 @@ internal static class OpenApiSchemaSampleGenerator MergeInto(merged, GenerateObjectSample(schema, depth, visiting)); } - return merged ?? firstNonObject; + if (merged is not null) + { + return merged; + } + + // for non-object values, combine the constraints of all branches + // so that the value satisfies all of them + return GenerateSample(CombineConstraints([schema, .. schema.AllOf ?? []]), depth + 1, visiting); + } + + private static OpenApiSchema CombineConstraints(IEnumerable schemas) + { + var combined = new OpenApiSchema(); + decimal? minimum = null, maximum = null; + + foreach (var schema in schemas) + { + if (schema is null) + { + continue; + } + +#pragma warning disable CS0618 + combined.Example ??= schema.Example?.DeepClone(); +#pragma warning restore CS0618 + combined.Default ??= schema.Default?.DeepClone(); + if (combined.Enum is not { Count: > 0 } && schema.Enum is { Count: > 0 }) + { + combined.Enum = [.. schema.Enum]; + } + combined.Type ??= schema.Type; + combined.Format ??= schema.Format; + combined.Items ??= schema.Items; + + if (TryParseBound(schema.Minimum?.ToString(), out var schemaMinimum) && (minimum is null || schemaMinimum > minimum)) + { + minimum = schemaMinimum; + } + if (TryParseBound(schema.Maximum?.ToString(), out var schemaMaximum) && (maximum is null || schemaMaximum < maximum)) + { + maximum = schemaMaximum; + } + if (schema.MinLength is int minLength && (combined.MinLength is null || minLength > combined.MinLength)) + { + combined.MinLength = minLength; + } + if (schema.MaxLength is int maxLength && (combined.MaxLength is null || maxLength < combined.MaxLength)) + { + combined.MaxLength = maxLength; + } + } + + combined.Minimum = minimum?.ToString(CultureInfo.InvariantCulture); + combined.Maximum = maximum?.ToString(CultureInfo.InvariantCulture); + return combined; } private static JsonObject GenerateObjectSample(IOpenApiSchema schema, int depth, HashSet visiting) @@ -248,6 +307,13 @@ private static void MergeInto(JsonObject target, JsonObject source) { foreach (var (key, value) in source) { + // merge nested objects so that properties from all sources are kept + if (value is JsonObject sourceObject && target[key] is JsonObject targetObject) + { + MergeInto(targetObject, sourceObject); + continue; + } + target[key] = value?.DeepClone(); } } @@ -348,7 +414,10 @@ private static JsonObject DescribeCore(IOpenApiSchema schema, int depth, HashSet } if (schema.Required is { Count: > 0 }) { - description["required"] = new JsonArray([.. schema.Required.Select(r => JsonValue.Create(r))]); + MergeDescription(description, new JsonObject + { + ["required"] = new JsonArray([.. schema.Required.Select(r => JsonValue.Create(r))]) + }); } if (schema.Items is not null) { @@ -382,6 +451,18 @@ private static void MergeDescription(JsonObject target, JsonObject? source) MergeInto(targetProperties, sourceProperties); continue; } + if (key == "required" && value is JsonArray sourceRequired && + target["required"] is JsonArray targetRequired) + { + foreach (var name in sourceRequired.Select(r => r?.GetValue()).Where(n => n is not null)) + { + if (!targetRequired.Any(r => r?.GetValue() == name)) + { + targetRequired.Add(name); + } + } + continue; + } target[key] = value?.DeepClone(); } From cb0e4c21d89fc9ba9547b27bb59db0d39c747c48 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 11:20:31 +0100 Subject: [PATCH 4/7] Handle more schema edge cases in OpenApiMockResponsePlugin - Pass through media types that can't be generated, like XML, unless the spec defines a string example - Skip the language model for null-only schemas - Resolve nested allOf when combining constraints - Keep null array items for null-only item schemas - Keep the stricter minimum/maximum when describing allOf schemas Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Mocking/OpenApiMockResponsePlugin.cs | 22 +++++- .../Mocking/OpenApiSchemaSampleGenerator.cs | 70 +++++++++++++++++-- 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/DevProxy.Plugins/Mocking/OpenApiMockResponsePlugin.cs b/DevProxy.Plugins/Mocking/OpenApiMockResponsePlugin.cs index 27fa75a1b..42fbbd771 100644 --- a/DevProxy.Plugins/Mocking/OpenApiMockResponsePlugin.cs +++ b/DevProxy.Plugins/Mocking/OpenApiMockResponsePlugin.cs @@ -121,6 +121,11 @@ public override async Task BeforeRequestAsync(ProxyRequestArgs e, CancellationTo var (statusCode, response) = GetMockResponse(operation); var (contentType, mediaType) = GetMediaType(response); + if (mediaType is not null && contentType is not null && !CanGenerateBody(contentType, mediaType)) + { + Logger.LogRequest($"Can't generate {contentType} response without an example in the OpenAPI spec", MessageType.Skipped, new LoggingContext(e.ProxySession)); + return; + } string? body = null; if (mediaType is not null && statusCode != HttpStatusCode.NoContent) @@ -274,6 +279,13 @@ private static (string? ContentType, IOpenApiMediaType? MediaType) GetMediaType( return (contentType, media.Value); } + // JSON and text bodies can be generated from the schema. For other media + // types, eg. XML, only string examples from the spec can be used as-is + private static bool CanGenerateBody(string contentType, IOpenApiMediaType mediaType) => + IsJsonContentType(contentType) || + contentType.StartsWith("text/", StringComparison.OrdinalIgnoreCase) || + GetExample(mediaType) is JsonValue example && example.GetValueKind() == JsonValueKind.String; + private static bool IsJsonContentType(string contentType) => contentType.Contains("json", StringComparison.OrdinalIgnoreCase) || contentType.Contains('*', StringComparison.Ordinal); @@ -344,9 +356,16 @@ private static string Serialize(JsonNode node, bool isJson) return null; } + var schemaDescription = OpenApiSchemaSampleGenerator.Describe(schema); + var expectedType = schemaDescription["type"]?.GetValue(); + if (expectedType == "null") + { + // the only valid value is null, so there's nothing to generate + return null; + } + Logger.LogInformation("Generating response for {Method} {Path} using language model...", method, pathTemplate); - var schemaDescription = OpenApiSchemaSampleGenerator.Describe(schema); var response = await _languageModelClient.GenerateChatCompletionAsync(PromptFileName, new() { { "request", $"{method} {pathTemplate}" }, @@ -354,7 +373,6 @@ private static string Serialize(JsonNode node, bool isJson) { "schema", schemaDescription.ToJsonString(ProxyUtils.JsonSerializerOptions) } }, cancellationToken); - var expectedType = schemaDescription["type"]?.GetValue(); var json = NormalizeToExpectedType(ExtractJson(response?.Response), expectedType); if (json is null) { diff --git a/DevProxy.Plugins/Mocking/OpenApiSchemaSampleGenerator.cs b/DevProxy.Plugins/Mocking/OpenApiSchemaSampleGenerator.cs index b7a15595f..5ffb835c4 100644 --- a/DevProxy.Plugins/Mocking/OpenApiSchemaSampleGenerator.cs +++ b/DevProxy.Plugins/Mocking/OpenApiSchemaSampleGenerator.cs @@ -141,7 +141,30 @@ internal static class OpenApiSchemaSampleGenerator // for non-object values, combine the constraints of all branches // so that the value satisfies all of them - return GenerateSample(CombineConstraints([schema, .. schema.AllOf ?? []]), depth + 1, visiting); + return GenerateSample(CombineConstraints(FlattenAllOf(schema, depth)), depth + 1, visiting); + } + + private static IEnumerable FlattenAllOf(IOpenApiSchema schema, int depth) + { + yield return schema; + + if (depth > MaxDepth) + { + yield break; + } + + foreach (var subSchema in schema.AllOf ?? []) + { + if (subSchema is null) + { + continue; + } + + foreach (var nested in FlattenAllOf(subSchema, depth + 1)) + { + yield return nested; + } + } } private static OpenApiSchema CombineConstraints(IEnumerable schemas) @@ -215,7 +238,15 @@ private static JsonObject GenerateObjectSample(IOpenApiSchema schema, int depth, private static JsonArray GenerateArraySample(IOpenApiSchema schema, int depth, HashSet visiting) { var item = GenerateSample(schema.Items, depth + 1, visiting); - return item is null ? [] : [item]; + if (item is null) + { + // null is a valid item when the item schema allows only null, + // otherwise it's a recursion cut-off + return schema.Items is not null && GetEffectiveType(schema.Items) == JsonSchemaType.Null ? + [null] : + []; + } + return [item]; } private static string GetStringSample(IOpenApiSchema schema) @@ -378,11 +409,11 @@ private static JsonObject DescribeCore(IOpenApiSchema schema, int depth, HashSet } if (schema.Minimum is not null) { - description["minimum"] = ToNumberNode(schema.Minimum.ToString()); + MergeDescription(description, new JsonObject { ["minimum"] = ToNumberNode(schema.Minimum.ToString()) }); } if (schema.Maximum is not null) { - description["maximum"] = ToNumberNode(schema.Maximum.ToString()); + MergeDescription(description, new JsonObject { ["maximum"] = ToNumberNode(schema.Maximum.ToString()) }); } if (schema.Enum is { Count: > 0 }) { @@ -431,6 +462,12 @@ private static JsonObject DescribeCore(IOpenApiSchema schema, int depth, HashSet return description; } + private static bool TryGetDecimal(JsonNode? node, out decimal value) + { + value = 0; + return node is JsonValue jsonValue && jsonValue.TryGetValue(out value); + } + private static JsonValue? ToNumberNode(string? value) => decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var number) ? JsonValue.Create(number) : @@ -448,7 +485,30 @@ private static void MergeDescription(JsonObject target, JsonObject? source) if (key == "properties" && value is JsonObject sourceProperties && target["properties"] is JsonObject targetProperties) { - MergeInto(targetProperties, sourceProperties); + foreach (var (name, sourceProperty) in sourceProperties) + { + if (sourceProperty is JsonObject sourcePropertyObject && + targetProperties[name] is JsonObject targetPropertyObject) + { + MergeDescription(targetPropertyObject, sourcePropertyObject); + } + else + { + targetProperties[name] = sourceProperty?.DeepClone(); + } + } + continue; + } + // allOf requires all constraints, so keep the stricter bound + if (key is "minimum" or "maximum" && + TryGetDecimal(value, out var sourceBound) && + TryGetDecimal(target[key], out var targetBound)) + { + var isStricter = key == "minimum" ? sourceBound > targetBound : sourceBound < targetBound; + if (isStricter) + { + target[key] = value!.DeepClone(); + } continue; } if (key == "required" && value is JsonArray sourceRequired && From c67a20207dbe422720ea486010871d4dd7253cc6 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 14:17:09 +0100 Subject: [PATCH 5/7] Honor more schema constraints in OpenApiMockResponsePlugin - Generate text/* bodies only from string schemas or string examples, pass other text responses through - Include length, pattern, multipleOf and array constraints in the schema sent to the language model - Generate minItems items and honor multipleOf in fallback samples - Add tests Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../OpenApiMockResponsePluginTests.cs | 207 ++++++++++++++++++ .../Mocking/OpenApiMockResponsePlugin.cs | 25 ++- .../Mocking/OpenApiSchemaSampleGenerator.cs | 76 ++++++- .../prompts/openapi_mock_response.prompty | 7 +- 4 files changed, 297 insertions(+), 18 deletions(-) create mode 100644 DevProxy.Integration.Tests/OpenApiMockResponsePluginTests.cs diff --git a/DevProxy.Integration.Tests/OpenApiMockResponsePluginTests.cs b/DevProxy.Integration.Tests/OpenApiMockResponsePluginTests.cs new file mode 100644 index 000000000..8bbccc8c3 --- /dev/null +++ b/DevProxy.Integration.Tests/OpenApiMockResponsePluginTests.cs @@ -0,0 +1,207 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Net; +using System.Text.Json.Nodes; +using DevProxy.Plugins.Mocking; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.OpenApi; +using Microsoft.OpenApi.Reader; +using Xunit; + +namespace DevProxy.Integration.Tests; + +/// +/// Coverage for served through the Kestrel engine +/// (with the language model disabled, so responses come from spec examples or the +/// schema-based fallback) and for . +/// +public sealed class OpenApiMockResponsePluginTests +{ + [Fact] + public async Task TextResponseWithStringSchema_IsMocked() + { + var (status, body, received) = await GetAsync("/text-string", """ + content: + text/plain: + schema: { type: string } + """); + + Assert.Equal(HttpStatusCode.OK, status); + Assert.Equal("string", body); + Assert.False(received); + } + + [Fact] + public async Task TextResponseWithObjectSchema_PassesThrough() + { + var (_, _, received) = await GetAsync("/text-object", """ + content: + text/plain: + schema: { type: object, properties: { a: { type: string } } } + """); + + Assert.True(received); + } + + [Fact] + public async Task TextResponseWithNonStringExample_PassesThrough() + { + var (_, _, received) = await GetAsync("/text-number", """ + content: + text/plain: + example: 42 + """); + + Assert.True(received); + } + + [Fact] + public async Task XmlResponseWithStringExample_IsMocked() + { + var (status, body, received) = await GetAsync("/xml", """ + content: + application/xml: + example: '1' + """); + + Assert.Equal(HttpStatusCode.OK, status); + Assert.Equal("1", body); + Assert.False(received); + } + + [Fact] + public async Task JsonResponseWithObjectSchema_IsMocked() + { + var (status, body, received) = await GetAsync("/json", """ + content: + application/json: + schema: { type: object, properties: { name: { type: string } } } + """); + + Assert.Equal(HttpStatusCode.OK, status); + Assert.Equal("string", JsonNode.Parse(body)!["name"]!.GetValue()); + Assert.False(received); + } + + [Theory] + [InlineData("{ type: array, minItems: 3, items: { type: integer } }", 3)] + [InlineData("{ type: array, items: { type: integer } }", 1)] + [InlineData("{ type: array, minItems: 5, maxItems: 2, items: { type: integer } }", 2)] + [InlineData("{ type: array, maxItems: 0, items: { type: integer } }", 0)] + public void GenerateSample_HonorsMinItems(string schema, int expectedCount) + { + var sample = OpenApiSchemaSampleGenerator.GenerateSample(ParseSchema(schema)); + + Assert.Equal(expectedCount, Assert.IsType(sample).Count); + } + + [Theory] + [InlineData("{ type: integer, minimum: 7, multipleOf: 5 }", "10")] + [InlineData("{ type: integer, maximum: -7, multipleOf: 5 }", "-10")] + [InlineData("{ type: number, minimum: 0.3, multipleOf: 0.25 }", "0.5")] + [InlineData("{ type: integer, minimum: 1, multipleOf: 0.5 }", "1")] + [InlineData("{ type: integer, minimum: 1.2, multipleOf: 1.5 }", "3")] + [InlineData("{ type: integer, multipleOf: 3 }", "0")] + public void GenerateSample_HonorsMultipleOf(string schema, string expected) + { + var sample = OpenApiSchemaSampleGenerator.GenerateSample(ParseSchema(schema)); + + Assert.Equal(decimal.Parse(expected, System.Globalization.CultureInfo.InvariantCulture), sample!.GetValue()); + } + + [Fact] + public void Describe_IncludesConstraints() + { + var description = OpenApiSchemaSampleGenerator.Describe(ParseSchema(""" + type: object + properties: + code: { type: string, minLength: 2, maxLength: 4, pattern: '^[A-Z]+$' } + amount: { type: number, multipleOf: 0.5 } + tags: { type: array, minItems: 1, maxItems: 3, uniqueItems: true, items: { type: string } } + """)); + + var properties = description["properties"]!; + Assert.Equal(2, properties["code"]!["minLength"]!.GetValue()); + Assert.Equal(4, properties["code"]!["maxLength"]!.GetValue()); + Assert.Equal("^[A-Z]+$", properties["code"]!["pattern"]!.GetValue()); + Assert.Equal(0.5m, properties["amount"]!["multipleOf"]!.GetValue()); + Assert.Equal(1, properties["tags"]!["minItems"]!.GetValue()); + Assert.Equal(3, properties["tags"]!["maxItems"]!.GetValue()); + Assert.True(properties["tags"]!["uniqueItems"]!.GetValue()); + } + + private static IOpenApiSchema ParseSchema(string schemaYaml) + { + var spec = string.Join('\n', [ + "openapi: 3.0.1", + "info: { title: Test, version: '1.0' }", + "paths: {}", + "components:", + " schemas:", + " Test:", + .. Indent(schemaYaml, 6) + ]); + var settings = new OpenApiReaderSettings(); + settings.AddYamlReader(); + var document = OpenApiDocument.Parse(spec, null, settings).Document!; + return document.Components!.Schemas!["Test"]; + } + + private static IEnumerable Indent(string yaml, int spaces) => + yaml.Split('\n').Select(l => new string(' ', spaces) + l.TrimEnd('\r')); + + private static async Task<(HttpStatusCode Status, string Body, bool OriginReceived)> GetAsync(string path, string responseYaml) + { + await using var origin = await FakeOrigin.StartAsync(); + + var dir = Directory.CreateTempSubdirectory("devproxy-openapimock-"); + try + { + var specFile = Path.Combine(dir.FullName, "spec.yaml"); + await File.WriteAllTextAsync(specFile, string.Join('\n', [ + "openapi: 3.0.1", + "info: { title: Test, version: '1.0' }", + "servers:", + $" - url: http://{origin.Host}", + "paths:", + $" {path}:", + " get:", + " responses:", + " '200':", + " description: OK", + .. Indent(responseYaml, 10) + ])); + + var proxyConfig = new TestProxyConfiguration + { + ConfigFile = Path.Combine(dir.FullName, "devproxyrc.json"), + }; + var config = PluginConfig.FromJson($$"""{ "specFile": "{{specFile.Replace("\\", "\\\\", StringComparison.Ordinal)}}" }"""); + + using var plugin = new OpenApiMockResponsePlugin( + TestDefaults.HttpClient, + NullLogger.Instance, + KestrelProxyHarness.BuildUrlsToWatch(origin.Host), + new DisabledLanguageModelClient(), + proxyConfig, + config); + + using var host = new PluginTestHost(proxyConfig); + await plugin.InitializeAsync(host.CreateInitArgs(), CancellationToken.None); + + await using var proxy = await KestrelProxyHarness.StartAsync(origin.Host, [plugin]); + using var client = proxy.CreateHttpClient(); + + using var httpResponse = await client.GetAsync(new Uri($"http://{origin.Host}{path}")); + var body = await httpResponse.Content.ReadAsStringAsync(); + + return (httpResponse.StatusCode, body, origin.ReceivedRequests.Any(r => r.PathAndQuery == path)); + } + finally + { + dir.Delete(recursive: true); + } + } +} diff --git a/DevProxy.Plugins/Mocking/OpenApiMockResponsePlugin.cs b/DevProxy.Plugins/Mocking/OpenApiMockResponsePlugin.cs index 42fbbd771..9bfca7878 100644 --- a/DevProxy.Plugins/Mocking/OpenApiMockResponsePlugin.cs +++ b/DevProxy.Plugins/Mocking/OpenApiMockResponsePlugin.cs @@ -279,12 +279,25 @@ private static (string? ContentType, IOpenApiMediaType? MediaType) GetMediaType( return (contentType, media.Value); } - // JSON and text bodies can be generated from the schema. For other media - // types, eg. XML, only string examples from the spec can be used as-is - private static bool CanGenerateBody(string contentType, IOpenApiMediaType mediaType) => - IsJsonContentType(contentType) || - contentType.StartsWith("text/", StringComparison.OrdinalIgnoreCase) || - GetExample(mediaType) is JsonValue example && example.GetValueKind() == JsonValueKind.String; + // JSON bodies can be generated from any schema. Text bodies only from + // string schemas. For other media types, eg. XML, and text with non-string + // schemas, only string examples from the spec can be used as-is + private static bool CanGenerateBody(string contentType, IOpenApiMediaType mediaType) + { + if (IsJsonContentType(contentType)) + { + return true; + } + + var example = GetExample(mediaType); + if (example is not null) + { + return example is JsonValue value && value.GetValueKind() == JsonValueKind.String; + } + + return contentType.StartsWith("text/", StringComparison.OrdinalIgnoreCase) && + OpenApiSchemaSampleGenerator.IsStringSchema(mediaType.Schema); + } private static bool IsJsonContentType(string contentType) => contentType.Contains("json", StringComparison.OrdinalIgnoreCase) || diff --git a/DevProxy.Plugins/Mocking/OpenApiSchemaSampleGenerator.cs b/DevProxy.Plugins/Mocking/OpenApiSchemaSampleGenerator.cs index 5ffb835c4..3e23d28da 100644 --- a/DevProxy.Plugins/Mocking/OpenApiSchemaSampleGenerator.cs +++ b/DevProxy.Plugins/Mocking/OpenApiSchemaSampleGenerator.cs @@ -24,6 +24,9 @@ internal static class OpenApiSchemaSampleGenerator /// public static JsonObject Describe(IOpenApiSchema? schema) => Describe(schema, 0, []) ?? []; + public static bool IsStringSchema(IOpenApiSchema? schema) => + schema is not null && Describe(schema)["type"]?.GetValue() == "string"; + private static string? GetReferenceId(IOpenApiSchema schema) => schema is OpenApiSchemaReference reference ? reference.Reference?.ReferenceV3 : null; @@ -238,15 +241,25 @@ private static JsonObject GenerateObjectSample(IOpenApiSchema schema, int depth, private static JsonArray GenerateArraySample(IOpenApiSchema schema, int depth, HashSet visiting) { var item = GenerateSample(schema.Items, depth + 1, visiting); - if (item is null) + // null is a valid item when the item schema allows only null, + // otherwise it's a recursion cut-off + if (item is null && (schema.Items is null || GetEffectiveType(schema.Items) != JsonSchemaType.Null)) + { + return []; + } + + var count = Math.Max(schema.MinItems ?? 1, 1); + if (schema.MaxItems is int maxItems) + { + count = Math.Min(count, Math.Max(maxItems, 0)); + } + + var array = new JsonArray(); + for (var i = 0; i < count; i++) { - // null is a valid item when the item schema allows only null, - // otherwise it's a recursion cut-off - return schema.Items is not null && GetEffectiveType(schema.Items) == JsonSchemaType.Null ? - [null] : - []; + array.Add(item?.DeepClone()); } - return [item]; + return array; } private static string GetStringSample(IOpenApiSchema schema) @@ -282,19 +295,34 @@ private static string GetStringSample(IOpenApiSchema schema) private static decimal GetNumber(IOpenApiSchema schema, bool isInteger) { - // use 0 unless it's outside the allowed range + var multipleOf = schema.MultipleOf > 0 ? schema.MultipleOf : null; + + // use 0 unless it's outside the allowed range. 0 is a multiple of any number if (TryParseBound(schema.Minimum?.ToString(), out var minimum) && minimum > 0) { - return isInteger ? decimal.Ceiling(minimum) : minimum; + var value = isInteger ? decimal.Ceiling(minimum) : minimum; + return multipleOf is decimal step ? RoundUpToMultiple(value, step, isInteger) : value; } if (TryParseBound(schema.Maximum?.ToString(), out var maximum) && maximum < 0) { - return isInteger ? decimal.Floor(maximum) : maximum; + var value = isInteger ? decimal.Floor(maximum) : maximum; + return multipleOf is decimal step ? -RoundUpToMultiple(-value, step, isInteger) : value; } return 0; } + private static decimal RoundUpToMultiple(decimal value, decimal step, bool isInteger) + { + var multiple = decimal.Ceiling(value / step) * step; + // for integers with a fractional step, eg. 0.5, find a multiple that's also an integer + while (isInteger && multiple != decimal.Truncate(multiple)) + { + multiple += step; + } + return multiple; + } + private static bool TryParseBound(string? bound, out decimal value) => decimal.TryParse(bound, NumberStyles.Float, CultureInfo.InvariantCulture, out value); @@ -443,6 +471,34 @@ private static JsonObject DescribeCore(IOpenApiSchema schema, int depth, HashSet } description["properties"] = properties; } + if (schema.MinLength is int minLength) + { + description["minLength"] = minLength; + } + if (schema.MaxLength is int maxLength) + { + description["maxLength"] = maxLength; + } + if (!string.IsNullOrEmpty(schema.Pattern)) + { + description["pattern"] = schema.Pattern; + } + if (schema.MultipleOf is not null) + { + description["multipleOf"] = schema.MultipleOf.Value; + } + if (schema.MinItems is int minItems) + { + description["minItems"] = minItems; + } + if (schema.MaxItems is int maxItems) + { + description["maxItems"] = maxItems; + } + if (schema.UniqueItems == true) + { + description["uniqueItems"] = true; + } if (schema.Required is { Count: > 0 }) { MergeDescription(description, new JsonObject diff --git a/DevProxy/prompts/openapi_mock_response.prompty b/DevProxy/prompts/openapi_mock_response.prompty index ada82320d..50550b4dc 100644 --- a/DevProxy/prompts/openapi_mock_response.prompty +++ b/DevProxy/prompts/openapi_mock_response.prompty @@ -20,13 +20,16 @@ response body for the API operation provided by the user. Follow these rules: - Use realistic values that match the meaning of each property name and its description, for example real-looking names for `givenName`, email addresses for `mail`, and plausible dates for date properties. -- Respect property types, formats and enum values defined in the schema. +- Respect property types, formats and enum values defined in the schema, as + well as constraints like `minimum`, `maximum`, `multipleOf`, `minLength`, + `maxLength`, `pattern`, `minItems`, `maxItems` and `uniqueItems`. - Include all properties defined in the schema. - If the schema type is `array`, respond with a JSON array, not an object wrapping the array. - If the schema type is `string`, `number`, `integer` or `boolean`, respond with only that JSON value, for example `42` or `"Alice"`, not an object. -- For arrays, include between 2 and 3 items. +- For arrays, include between 2 and 3 items, unless `minItems` or `maxItems` + require otherwise. - Respond only with the JSON. Don't include explanations, comments or Markdown code fences. From 21419b05344171342afc373777e0554eebea20f7 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 14:28:18 +0100 Subject: [PATCH 6/7] Preserve allOf constraints and generate distinct unique items in OpenApiMockResponsePlugin - Keep multipleOf, minItems, maxItems, uniqueItems and pattern when combining allOf - Compute integer-compatible multiples directly instead of searching - Generate distinct items for uniqueItems arrays (best-effort) - Test language model output handling and caching with a fake client Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../OpenApiMockResponsePluginTests.cs | 138 ++++++++++- .../Mocking/OpenApiSchemaSampleGenerator.cs | 214 +++++++++++++++++- 2 files changed, 339 insertions(+), 13 deletions(-) diff --git a/DevProxy.Integration.Tests/OpenApiMockResponsePluginTests.cs b/DevProxy.Integration.Tests/OpenApiMockResponsePluginTests.cs index 8bbccc8c3..c316794e5 100644 --- a/DevProxy.Integration.Tests/OpenApiMockResponsePluginTests.cs +++ b/DevProxy.Integration.Tests/OpenApiMockResponsePluginTests.cs @@ -4,6 +4,7 @@ using System.Net; using System.Text.Json.Nodes; +using DevProxy.Abstractions.LanguageModel; using DevProxy.Plugins.Mocking; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.OpenApi; @@ -90,6 +91,8 @@ public async Task JsonResponseWithObjectSchema_IsMocked() [InlineData("{ type: array, items: { type: integer } }", 1)] [InlineData("{ type: array, minItems: 5, maxItems: 2, items: { type: integer } }", 2)] [InlineData("{ type: array, maxItems: 0, items: { type: integer } }", 0)] + [InlineData("{ allOf: [ { type: array, minItems: 3, items: { type: integer } } ] }", 3)] + [InlineData("{ allOf: [ { type: array, minItems: 5, items: { type: integer } }, { maxItems: 2 } ] }", 2)] public void GenerateSample_HonorsMinItems(string schema, int expectedCount) { var sample = OpenApiSchemaSampleGenerator.GenerateSample(ParseSchema(schema)); @@ -104,6 +107,9 @@ public void GenerateSample_HonorsMinItems(string schema, int expectedCount) [InlineData("{ type: integer, minimum: 1, multipleOf: 0.5 }", "1")] [InlineData("{ type: integer, minimum: 1.2, multipleOf: 1.5 }", "3")] [InlineData("{ type: integer, multipleOf: 3 }", "0")] + [InlineData("{ type: integer, minimum: 1.2, multipleOf: 0.0000000013 }", "13")] + [InlineData("{ allOf: [ { type: integer, minimum: 7, multipleOf: 5 } ] }", "10")] + [InlineData("{ allOf: [ { type: integer, multipleOf: 4 }, { minimum: 1, multipleOf: 6 } ] }", "12")] public void GenerateSample_HonorsMultipleOf(string schema, string expected) { var sample = OpenApiSchemaSampleGenerator.GenerateSample(ParseSchema(schema)); @@ -111,6 +117,86 @@ public void GenerateSample_HonorsMultipleOf(string schema, string expected) Assert.Equal(decimal.Parse(expected, System.Globalization.CultureInfo.InvariantCulture), sample!.GetValue()); } + [Theory] + [InlineData("{ type: array, minItems: 3, uniqueItems: true, items: { type: integer } }", "[0,1,2]")] + [InlineData("{ type: array, minItems: 3, uniqueItems: true, items: { type: integer, maximum: -10, multipleOf: 5 } }", "[-10,-15,-20]")] + [InlineData("{ type: array, minItems: 2, uniqueItems: true, items: { type: string, maxLength: 6 } }", "[\"string\",\"strin1\"]")] + [InlineData("{ type: array, minItems: 2, uniqueItems: true, items: { type: string, format: email } }", "[\"user@example.com\",\"user1@example.com\"]")] + [InlineData("{ type: array, minItems: 3, uniqueItems: true, items: { type: boolean } }", "[true,false]")] + [InlineData("{ type: array, minItems: 3, uniqueItems: true, items: { type: string, enum: [a, b] } }", "[\"a\",\"b\"]")] + [InlineData("{ type: array, minItems: 3, uniqueItems: true, items: { type: string, format: date } }", "[\"2024-01-01\"]")] + [InlineData("{ type: array, minItems: 2, uniqueItems: true, items: { type: object, properties: { id: { type: integer }, name: { type: string } } } }", "[{\"id\":0,\"name\":\"string\"},{\"id\":1,\"name\":\"string\"}]")] + [InlineData("{ type: array, minItems: 2, items: { type: integer } }", "[0,0]")] + public void GenerateSample_HonorsUniqueItems(string schema, string expected) + { + var sample = OpenApiSchemaSampleGenerator.GenerateSample(ParseSchema(schema)); + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), sample), sample?.ToJsonString()); + } + + [Fact] + public async Task LanguageModelResponse_IsUsedAndCached() + { + var client = new FakeLanguageModelClient("```json\n{ \"name\": \"Megan Bowen\" }\n```"); + + var results = await GetAsync("/lm", """ + content: + application/json: + schema: { type: object, properties: { name: { type: string } } } + """, client, requestCount: 2); + + Assert.All(results, r => Assert.Equal("Megan Bowen", JsonNode.Parse(r.Body)!["name"]!.GetValue())); + Assert.Equal(1, client.CompletionCount); + } + + [Theory] + [InlineData("not json")] + [InlineData("[1, 2]")] + [InlineData(null)] + public async Task InvalidLanguageModelResponse_FallsBackToSchema(string? response) + { + var client = new FakeLanguageModelClient(response); + + var results = await GetAsync("/lm-invalid", """ + content: + application/json: + schema: { type: object, properties: { name: { type: string } } } + """, client); + + Assert.Equal("string", JsonNode.Parse(results[0].Body)!["name"]!.GetValue()); + Assert.False(results[0].OriginReceived); + } + + [Fact] + public async Task LanguageModelScalarWrapper_IsUnwrapped() + { + var client = new FakeLanguageModelClient("{ \"count\": 42 }"); + + var results = await GetAsync("/lm-count", """ + content: + application/json: + schema: { type: integer } + """, client); + + Assert.Equal("42", results[0].Body); + } + + [Fact] + public async Task ResponseWithExample_DoesNotCallLanguageModel() + { + var client = new FakeLanguageModelClient("{ \"name\": \"Megan Bowen\" }"); + + var results = await GetAsync("/example", """ + content: + application/json: + schema: { type: object, properties: { name: { type: string } } } + example: { name: Adele Vance } + """, client); + + Assert.Equal("Adele Vance", JsonNode.Parse(results[0].Body)!["name"]!.GetValue()); + Assert.Equal(0, client.CompletionCount); + } + [Fact] public void Describe_IncludesConstraints() { @@ -152,7 +238,11 @@ .. Indent(schemaYaml, 6) private static IEnumerable Indent(string yaml, int spaces) => yaml.Split('\n').Select(l => new string(' ', spaces) + l.TrimEnd('\r')); - private static async Task<(HttpStatusCode Status, string Body, bool OriginReceived)> GetAsync(string path, string responseYaml) + private static async Task<(HttpStatusCode Status, string Body, bool OriginReceived)> GetAsync(string path, string responseYaml) => + (await GetAsync(path, responseYaml, new DisabledLanguageModelClient()))[0]; + + private static async Task> GetAsync( + string path, string responseYaml, ILanguageModelClient languageModelClient, int requestCount = 1) { await using var origin = await FakeOrigin.StartAsync(); @@ -184,7 +274,7 @@ .. Indent(responseYaml, 10) TestDefaults.HttpClient, NullLogger.Instance, KestrelProxyHarness.BuildUrlsToWatch(origin.Host), - new DisabledLanguageModelClient(), + languageModelClient, proxyConfig, config); @@ -194,14 +284,50 @@ .. Indent(responseYaml, 10) await using var proxy = await KestrelProxyHarness.StartAsync(origin.Host, [plugin]); using var client = proxy.CreateHttpClient(); - using var httpResponse = await client.GetAsync(new Uri($"http://{origin.Host}{path}")); - var body = await httpResponse.Content.ReadAsStringAsync(); - - return (httpResponse.StatusCode, body, origin.ReceivedRequests.Any(r => r.PathAndQuery == path)); + var results = new List<(HttpStatusCode, string, bool)>(); + for (var i = 0; i < requestCount; i++) + { + using var httpResponse = await client.GetAsync(new Uri($"http://{origin.Host}{path}")); + var body = await httpResponse.Content.ReadAsStringAsync(); + results.Add((httpResponse.StatusCode, body, origin.ReceivedRequests.Any(r => r.PathAndQuery == path))); + } + return results; } finally { dir.Delete(recursive: true); } } + + private sealed class FakeLanguageModelClient(string? response) : ILanguageModelClient + { + private int _completionCount; + + public int CompletionCount => _completionCount; + + public Task GenerateChatCompletionAsync( + string promptFileName, Dictionary parameters, CancellationToken cancellationToken) + { + _ = Interlocked.Increment(ref _completionCount); + return Task.FromResult(new FakeCompletionResponse(response)); + } + + public Task GenerateChatCompletionAsync( + IEnumerable messages, CompletionOptions? options, CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task GenerateCompletionAsync( + string prompt, CompletionOptions? options, CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task IsEnabledAsync(CancellationToken cancellationToken) => Task.FromResult(true); + } + + private sealed class FakeCompletionResponse(string? response) : ILanguageModelCompletionResponse + { + public string? ErrorMessage => null; + public string? Response => response; + public string? RequestUrl { get; set; } + public OpenAIResponse ConvertToOpenAIResponse() => throw new NotSupportedException(); + } } diff --git a/DevProxy.Plugins/Mocking/OpenApiSchemaSampleGenerator.cs b/DevProxy.Plugins/Mocking/OpenApiSchemaSampleGenerator.cs index 3e23d28da..a1ae2b2be 100644 --- a/DevProxy.Plugins/Mocking/OpenApiSchemaSampleGenerator.cs +++ b/DevProxy.Plugins/Mocking/OpenApiSchemaSampleGenerator.cs @@ -4,6 +4,7 @@ using Microsoft.OpenApi; using System.Globalization; +using System.Text.Json; using System.Text.Json.Nodes; namespace DevProxy.Plugins.Mocking; @@ -210,6 +211,23 @@ private static OpenApiSchema CombineConstraints(IEnumerable sch { combined.MaxLength = maxLength; } + if (schema.MinItems is int minItems && (combined.MinItems is null || minItems > combined.MinItems)) + { + combined.MinItems = minItems; + } + if (schema.MaxItems is int maxItems && (combined.MaxItems is null || maxItems < combined.MaxItems)) + { + combined.MaxItems = maxItems; + } + if (schema.UniqueItems == true) + { + combined.UniqueItems = true; + } + combined.Pattern ??= schema.Pattern; + if (schema.MultipleOf is decimal multipleOf && multipleOf > 0) + { + combined.MultipleOf = combined.MultipleOf is decimal current ? LeastCommonMultiple(current, multipleOf) : multipleOf; + } } combined.Minimum = minimum?.ToString(CultureInfo.InvariantCulture); @@ -255,13 +273,145 @@ private static JsonArray GenerateArraySample(IOpenApiSchema schema, int depth, H } var array = new JsonArray(); - for (var i = 0; i < count; i++) + if (schema.UniqueItems != true) { - array.Add(item?.DeepClone()); + for (var i = 0; i < count; i++) + { + array.Add(item?.DeepClone()); + } + return array; + } + + // best-effort: vary the sample to get distinct items. If we can't, + // return fewer items rather than duplicates + array.Add(item?.DeepClone()); + for (var attempt = 1; array.Count < count && attempt < count * 4; attempt++) + { + var variant = CreateVariant(item, schema.Items, attempt, depth); + if (variant is not null && !array.Any(i => JsonNode.DeepEquals(i, variant))) + { + array.Add(variant); + } } return array; } + private static JsonNode? CreateVariant(JsonNode? sample, IOpenApiSchema? schema, int index, int depth) + { + if (schema is null || sample is null || depth > MaxDepth) + { + return null; + } + + var constraints = schema.AllOf is { Count: > 0 } ? CombineConstraints(FlattenAllOf(schema, depth)) : schema; + var enumValues = constraints.Enum?.Where(e => e is not null).ToArray(); + if (enumValues is { Length: > 0 }) + { + return index < enumValues.Length ? enumValues[index]!.DeepClone() : null; + } + + if (sample is JsonObject obj) + { + foreach (var (name, value) in obj) + { + if (value is not JsonValue || FindPropertySchema(schema, name, depth) is not IOpenApiSchema propertySchema) + { + continue; + } + + var variant = CreateVariant(value, propertySchema, index, depth + 1); + if (variant is not null) + { + var clone = obj.DeepClone().AsObject(); + clone[name] = variant; + return clone; + } + } + return null; + } + + if (sample is not JsonValue scalar) + { + return null; + } + + switch (scalar.GetValueKind()) + { + case JsonValueKind.Number when scalar.TryGetValue(out var number): + return CreateNumberVariant(number, constraints, index); + case JsonValueKind.True or JsonValueKind.False when index == 1: + return JsonValue.Create(scalar.GetValueKind() == JsonValueKind.False); + case JsonValueKind.String when scalar.TryGetValue(out var text): + return CreateStringVariant(text, constraints, index) is string variant ? JsonValue.Create(variant) : null; + default: + return null; + } + } + + private static JsonValue? CreateNumberVariant(decimal number, IOpenApiSchema schema, int index) + { + try + { + var isInteger = GetEffectiveType(schema) == JsonSchemaType.Integer; + var step = schema.MultipleOf > 0 ? schema.MultipleOf.Value : 1m; + if (isInteger) + { + step = GetIntegerStep(step); + } + + var hasMinimum = TryParseBound(schema.Minimum?.ToString(), out var minimum); + var hasMaximum = TryParseBound(schema.Maximum?.ToString(), out var maximum); + + var up = number + (index * step); + if (!hasMaximum || up <= maximum) + { + return JsonValue.Create(up); + } + var down = number - (index * step); + return !hasMinimum || down >= minimum ? JsonValue.Create(down) : null; + } + catch (OverflowException) + { + return null; + } + } + + private static string? CreateStringVariant(string text, IOpenApiSchema schema, int index) + { + var suffix = index.ToString(CultureInfo.InvariantCulture); + switch (schema.Format?.ToLowerInvariant()) + { + case "uuid" when text.Length > 0: + return text[..^1] + (index % 16).ToString("x", CultureInfo.InvariantCulture); + case "email" or "idn-email" when text.Contains('@', StringComparison.Ordinal): + var at = text.IndexOf('@', StringComparison.Ordinal); + return text[..at] + suffix + text[at..]; + case null or "": + if (schema.MaxLength is int maxLength && text.Length + suffix.Length > maxLength) + { + return maxLength >= suffix.Length ? text[..(maxLength - suffix.Length)] + suffix : null; + } + return text + suffix; + default: + // other formats, like dates, can't be varied safely + return null; + } + } + + private static IOpenApiSchema? FindPropertySchema(IOpenApiSchema schema, string name, int depth) + { + foreach (var candidate in FlattenAllOf(schema, depth)) + { + if (candidate.Properties?.TryGetValue(name, out var propertySchema) == true) + { + return propertySchema; + } + } + + var alternative = schema.OneOf?.FirstOrDefault() ?? schema.AnyOf?.FirstOrDefault(); + return alternative is not null && depth <= MaxDepth ? FindPropertySchema(alternative, name, depth + 1) : null; + } + private static string GetStringSample(IOpenApiSchema schema) { var value = schema.Format?.ToLowerInvariant() switch @@ -314,13 +464,63 @@ private static decimal GetNumber(IOpenApiSchema schema, bool isInteger) private static decimal RoundUpToMultiple(decimal value, decimal step, bool isInteger) { - var multiple = decimal.Ceiling(value / step) * step; - // for integers with a fractional step, eg. 0.5, find a multiple that's also an integer - while (isInteger && multiple != decimal.Truncate(multiple)) + try + { + if (isInteger) + { + step = GetIntegerStep(step); + } + return decimal.Ceiling(value / step) * step; + } + catch (OverflowException) + { + return value; + } + } + + // the smallest integer that's a multiple of step. For step p/q in lowest + // terms, that's p, eg. 1.5 = 3/2 -> 3 + private static decimal GetIntegerStep(decimal step) + { + var denominator = Pow10(step.Scale); + var numerator = step * denominator; + return numerator / GreatestCommonDivisor(numerator, denominator); + } + + private static decimal LeastCommonMultiple(decimal a, decimal b) + { + try + { + var scale = Pow10(Math.Max(a.Scale, b.Scale)); + var scaledA = a * scale; + var scaledB = b * scale; + return scaledA / GreatestCommonDivisor(scaledA, scaledB) * scaledB / scale; + } + catch (OverflowException) + { + return Math.Max(a, b); + } + } + + private static decimal GreatestCommonDivisor(decimal a, decimal b) + { + a = decimal.Truncate(a); + b = decimal.Truncate(b); + while (b != 0) + { + (a, b) = (b, a % b); + } + return a; + } + + private static decimal Pow10(int exponent) + { + var result = 1m; + for (var i = 0; i < exponent; i++) { - multiple += step; + result *= 10; } - return multiple; + return result; } private static bool TryParseBound(string? bound, out decimal value) => From fc85e140d4d8644b2b8241478708760990c9d5b4 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 31 Oct 2026 14:35:51 +0100 Subject: [PATCH 7/7] Respect maxItems: 0 for unique arrays in OpenApiMockResponsePlugin Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy.Integration.Tests/OpenApiMockResponsePluginTests.cs | 1 + DevProxy.Plugins/Mocking/OpenApiSchemaSampleGenerator.cs | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/DevProxy.Integration.Tests/OpenApiMockResponsePluginTests.cs b/DevProxy.Integration.Tests/OpenApiMockResponsePluginTests.cs index c316794e5..1931aada7 100644 --- a/DevProxy.Integration.Tests/OpenApiMockResponsePluginTests.cs +++ b/DevProxy.Integration.Tests/OpenApiMockResponsePluginTests.cs @@ -127,6 +127,7 @@ public void GenerateSample_HonorsMultipleOf(string schema, string expected) [InlineData("{ type: array, minItems: 3, uniqueItems: true, items: { type: string, format: date } }", "[\"2024-01-01\"]")] [InlineData("{ type: array, minItems: 2, uniqueItems: true, items: { type: object, properties: { id: { type: integer }, name: { type: string } } } }", "[{\"id\":0,\"name\":\"string\"},{\"id\":1,\"name\":\"string\"}]")] [InlineData("{ type: array, minItems: 2, items: { type: integer } }", "[0,0]")] + [InlineData("{ type: array, maxItems: 0, uniqueItems: true, items: { type: integer } }", "[]")] public void GenerateSample_HonorsUniqueItems(string schema, string expected) { var sample = OpenApiSchemaSampleGenerator.GenerateSample(ParseSchema(schema)); diff --git a/DevProxy.Plugins/Mocking/OpenApiSchemaSampleGenerator.cs b/DevProxy.Plugins/Mocking/OpenApiSchemaSampleGenerator.cs index a1ae2b2be..f1312ceeb 100644 --- a/DevProxy.Plugins/Mocking/OpenApiSchemaSampleGenerator.cs +++ b/DevProxy.Plugins/Mocking/OpenApiSchemaSampleGenerator.cs @@ -284,6 +284,10 @@ private static JsonArray GenerateArraySample(IOpenApiSchema schema, int depth, H // best-effort: vary the sample to get distinct items. If we can't, // return fewer items rather than duplicates + if (count == 0) + { + return array; + } array.Add(item?.DeepClone()); for (var attempt = 1; array.Count < count && attempt < count * 4; attempt++) {