diff --git a/DevProxy.Integration.Tests/OpenApiMockResponsePluginTests.cs b/DevProxy.Integration.Tests/OpenApiMockResponsePluginTests.cs
new file mode 100644
index 000000000..1931aada7
--- /dev/null
+++ b/DevProxy.Integration.Tests/OpenApiMockResponsePluginTests.cs
@@ -0,0 +1,334 @@
+// 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.Abstractions.LanguageModel;
+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)]
+ [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));
+
+ 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")]
+ [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));
+
+ 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]")]
+ [InlineData("{ type: array, maxItems: 0, uniqueItems: true, items: { type: integer } }", "[]")]
+ 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()
+ {
+ 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 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();
+
+ 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),
+ languageModelClient,
+ 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();
+
+ 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/OpenApiMockResponsePlugin.cs b/DevProxy.Plugins/Mocking/OpenApiMockResponsePlugin.cs
new file mode 100644
index 000000000..9bfca7878
--- /dev/null
+++ b/DevProxy.Plugins/Mocking/OpenApiMockResponsePlugin.cs
@@ -0,0 +1,503 @@
+// 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;
+
+ // 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);
+
+ 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 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 = 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));
+ 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);
+ 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)
+ {
+ var cacheKey = $"{method.Method} {pathTemplate}";
+ 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)));
+ 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();
+ 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);
+ }
+
+ PrioritizeConcretePaths(document);
+ // the spec changed, so previously generated responses might be invalid
+ _snapshot = new(document, new(StringComparer.Ordinal));
+
+ 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);
+ }
+ }
+
+ // 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;
+ 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);
+ }
+
+ // 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) ||
+ 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);
+ 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)
+ {
+ 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;
+ }
+
+ 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 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 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)
+ {
+ // 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:
+ // 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;
+ 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))
+ {
+ 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)
+ {
+ return null;
+ }
+
+ return TryParseJson(response[start..(end + 1)], out json) ? json : null;
+ }
+
+ private static bool TryParseJson(string value, out JsonNode? json)
+ {
+ try
+ {
+ json = JsonNode.Parse(value);
+ return json is not null;
+ }
+ catch (JsonException)
+ {
+ json = null;
+ return false;
+ }
+ }
+
+ private sealed record SpecSnapshot(
+ OpenApiDocument Document,
+ ConcurrentDictionary>> ResponseCache);
+
+ 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..f1312ceeb
--- /dev/null
+++ b/DevProxy.Plugins/Mocking/OpenApiSchemaSampleGenerator.cs
@@ -0,0 +1,790 @@
+// 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;
+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, []) ?? [];
+
+ 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;
+
+ 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)
+ {
+ 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
+ {
+ JsonSchemaType.Object => GenerateObjectSample(schema, depth, visiting),
+ JsonSchemaType.Array => GenerateArraySample(schema, depth, visiting),
+ 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))
+ };
+ }
+
+ private static JsonNode? GenerateAllOfSample(IOpenApiSchema schema, int depth, HashSet visiting)
+ {
+ JsonObject? merged = null;
+
+ foreach (var subSchema in schema.AllOf ?? [])
+ {
+ if (GenerateSample(subSchema, depth + 1, visiting) is JsonObject obj)
+ {
+ merged ??= [];
+ MergeInto(merged, obj);
+ }
+ }
+
+ if (schema.Properties is { Count: > 0 })
+ {
+ merged ??= [];
+ MergeInto(merged, GenerateObjectSample(schema, depth, visiting));
+ }
+
+ 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(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)
+ {
+ 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;
+ }
+ 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);
+ combined.Maximum = maximum?.ToString(CultureInfo.InvariantCulture);
+ return combined;
+ }
+
+ 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);
+ // 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();
+ if (schema.UniqueItems != true)
+ {
+ 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
+ if (count == 0)
+ {
+ return array;
+ }
+ 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
+ {
+ "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 decimal GetNumber(IOpenApiSchema schema, bool isInteger)
+ {
+ 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)
+ {
+ 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)
+ {
+ 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)
+ {
+ 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++)
+ {
+ result *= 10;
+ }
+ return result;
+ }
+
+ 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)
+ {
+ // 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)
+ {
+ // 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();
+ }
+ }
+
+ 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)
+ {
+ MergeDescription(description, new JsonObject { ["minimum"] = ToNumberNode(schema.Minimum.ToString()) });
+ }
+ if (schema.Maximum is not null)
+ {
+ MergeDescription(description, new JsonObject { ["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.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
+ {
+ ["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 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) :
+ 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)
+ {
+ 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 &&
+ 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();
+ }
+ }
+}
diff --git a/DevProxy/prompts/openapi_mock_response.prompty b/DevProxy/prompts/openapi_mock_response.prompty
new file mode 100644
index 000000000..50550b4dc
--- /dev/null
+++ b/DevProxy/prompts/openapi_mock_response.prompty
@@ -0,0 +1,40 @@
+---
+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, 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, unless `minItems` or `maxItems`
+ require otherwise.
+- 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
+}