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 +}