diff --git a/DevProxy.Integration.Tests/OpenApiValidationPluginTests.cs b/DevProxy.Integration.Tests/OpenApiValidationPluginTests.cs new file mode 100644 index 00000000..057cfb02 --- /dev/null +++ b/DevProxy.Integration.Tests/OpenApiValidationPluginTests.cs @@ -0,0 +1,1184 @@ +// 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 DevProxy.Abstractions.Proxy; +using DevProxy.Abstractions.Utils; +using DevProxy.Plugins.Reporting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace DevProxy.Integration.Tests; + +/// +/// Coverage for , which validates intercepted +/// requests and responses against local OpenAPI specs. +/// +public sealed class OpenApiValidationPluginTests : IDisposable +{ + private const string Spec = """ + { + "openapi": "3.0.3", + "info": { "title": "Contoso", "version": "1.0" }, + "servers": [ { "url": "https://api.contoso.com/v1" } ], + "paths": { + "/users": { + "get": { + "parameters": [ + { "name": "top", "in": "query", "schema": { "type": "integer" } }, + { "name": "status", "in": "query", "schema": { "type": "string", "enum": [ "active", "inactive" ] } }, + { "name": "x-tenant", "in": "header", "required": true, "schema": { "type": "string" } } + ], + "responses": { + "200": { + "description": "ok", + "headers": { + "x-total-count": { "required": true, "schema": { "type": "integer" } } + }, + "content": { + "application/json": { + "schema": { "type": "array", "items": { "$ref": "#/components/schemas/User" } } + } + } + }, + "4XX": { + "description": "client error", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } + } + } + }, + "post": { + "requestBody": { + "required": true, + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/User" } } + } + }, + "responses": { + "201": { + "description": "created", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } } + } + } + } + }, + "/users/{id}": { + "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } } ], + "get": { + "responses": { + "200": { + "description": "ok", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } } + } + } + } + }, + "/users/me": { + "get": { + "responses": { + "200": { + "description": "ok", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } } + } + } + } + }, + "/pets": { + "get": { + "responses": { + "default": { + "description": "pets", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "oneOf": [ { "$ref": "#/components/schemas/Cat" }, { "$ref": "#/components/schemas/Dog" } ] + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "User": { + "type": "object", + "required": [ "id", "name" ], + "additionalProperties": false, + "properties": { + "id": { "type": "integer", "readOnly": true }, + "name": { "type": "string" }, + "nickname": { "type": "string", "nullable": true }, + "role": { "type": "string", "enum": [ "admin", "user" ] }, + "tags": { "type": "array", "items": { "type": "string" } } + } + }, + "Error": { + "type": "object", + "required": [ "message" ], + "properties": { "message": { "type": "string" } } + }, + "Cat": { + "type": "object", + "required": [ "meows" ], + "additionalProperties": false, + "properties": { "meows": { "type": "boolean" } } + }, + "Dog": { + "type": "object", + "required": [ "barks" ], + "additionalProperties": false, + "properties": { "barks": { "type": "boolean" } } + } + } + } + } + """; + + private readonly DirectoryInfo _dir = Directory.CreateTempSubdirectory("devproxy-oasvalidation-"); + + public void Dispose() => _dir.Delete(recursive: true); + + private static RecordingArgs Recording(IEnumerable logs) => + new(logs) + { + GlobalData = new() { [ProxyUtils.ReportsKey] = new Dictionary() }, + }; + + private async Task CreatePluginAsync(string configJson = """{ "specFiles": [ "contoso.json" ] }""", + ILogger? logger = null) + { + await File.WriteAllTextAsync(Path.Combine(_dir.FullName, "contoso.json"), Spec); + + var proxyConfig = new TestProxyConfiguration + { + ConfigFile = Path.Combine(_dir.FullName, "devproxyrc.json"), + }; + var plugin = new OpenApiValidationPlugin( + TestDefaults.HttpClient, + logger ?? NullLogger.Instance, + KestrelProxyHarness.BuildUrlsToWatch("api.contoso.com"), + proxyConfig, + PluginConfig.FromJson(configJson)); + + using var host = new PluginTestHost(proxyConfig); + await plugin.InitializeAsync(host.CreateInitArgs(), CancellationToken.None); + return plugin; + } + + private static TestExchange Get(string url, HttpStatusCode status, string? body, + IEnumerable<(string, string)>? requestHeaders = null, + IEnumerable<(string, string)>? responseHeaders = null) => + TestExchange + .Request("GET", url, headers: requestHeaders) + .WithResponse( + status, + headers: [("Content-Type", "application/json"), .. responseHeaders ?? []], + body: body); + + private static string[] Messages(OpenApiValidationResult? result) => + [.. Assert.IsType(result).Issues.Select(i => i.ToString())]; + + [Fact] + public async Task ValidRequestAndResponse_HasNoIssues() + { + var plugin = await CreatePluginAsync(); + var exchange = Get("https://api.contoso.com/v1/users?top=10&status=active", HttpStatusCode.OK, + """[ { "id": 1, "name": "Alex", "nickname": null, "role": "admin", "tags": [ "a" ] } ]""", + requestHeaders: [("x-tenant", "contoso")], + responseHeaders: [("x-total-count", "1")]); + + var result = plugin.Validate(exchange.Session); + + Assert.NotNull(result); + Assert.Equal("GET /users", result.Operation); + Assert.Empty(result.Issues); + } + + [Fact] + public async Task ReportsParameterIssues() + { + var plugin = await CreatePluginAsync(); + var exchange = Get("https://api.contoso.com/v1/users?top=abc&status=deleted&foo=bar", HttpStatusCode.OK, + "[]", responseHeaders: [("x-total-count", "0")]); + + var messages = Messages(plugin.Validate(exchange.Session)); + + Assert.Contains(messages, m => m.Contains("Missing required header parameter 'x-tenant'", StringComparison.Ordinal)); + Assert.Contains(messages, m => m.StartsWith("top:", StringComparison.Ordinal) && m.Contains("Expected integer", StringComparison.Ordinal)); + Assert.Contains(messages, m => m.StartsWith("status:", StringComparison.Ordinal) && m.Contains("allowed values", StringComparison.Ordinal)); + Assert.Contains(messages, m => m.Contains("Query parameter 'foo' isn't documented", StringComparison.Ordinal)); + Assert.Equal(4, messages.Length); + } + + [Fact] + public async Task ReportsInvalidPathParameter() + { + var plugin = await CreatePluginAsync(); + var exchange = Get("https://api.contoso.com/v1/users/abc", HttpStatusCode.OK, """{ "id": 1, "name": "Alex" }"""); + + var result = plugin.Validate(exchange.Session); + + Assert.Equal("GET /users/{id}", result?.Operation); + var issue = Assert.Single(result!.Issues); + Assert.Equal("id", issue.Path); + Assert.Contains("Expected integer", issue.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task PrefersLiteralPathOverTemplate() + { + var plugin = await CreatePluginAsync(); + var exchange = Get("https://api.contoso.com/v1/users/me", HttpStatusCode.OK, """{ "id": 1, "name": "Alex" }"""); + + var result = plugin.Validate(exchange.Session); + + Assert.Equal("GET /users/me", result?.Operation); + Assert.Empty(result!.Issues); + } + + [Fact] + public async Task ReportsUndocumentedPathAndMethod() + { + var plugin = await CreatePluginAsync(); + + var path = plugin.Validate(Get("https://api.contoso.com/v1/orders", HttpStatusCode.OK, "[]").Session); + var method = plugin.Validate(TestExchange.Request("DELETE", "https://api.contoso.com/v1/users").WithResponse(HttpStatusCode.NoContent).Session); + + Assert.Contains("Path '/orders' isn't documented", Assert.Single(Messages(path)), StringComparison.Ordinal); + Assert.Contains("Method DELETE isn't documented for path '/users'", Assert.Single(Messages(method)), StringComparison.Ordinal); + } + + [Fact] + public async Task ReportsRequestBodyIssues() + { + var plugin = await CreatePluginAsync(); + var exchange = TestExchange + .Request("POST", "https://api.contoso.com/v1/users", + headers: [("Content-Type", "application/json")], + body: """{ "name": 42, "role": "guest", "unknown": true }""") + .WithResponse(HttpStatusCode.Created, headers: [("Content-Type", "application/json")], + body: """{ "id": 1, "name": "Alex" }"""); + + var result = plugin.Validate(exchange.Session); + var messages = Messages(result); + + // id is readOnly so it's not required in the request + Assert.DoesNotContain(messages, m => m.Contains("'id'", StringComparison.Ordinal)); + Assert.Contains("$.name: Expected string but got integer", messages); + Assert.Contains(messages, m => m.StartsWith("$.role:", StringComparison.Ordinal)); + Assert.Contains("$.unknown: Property 'unknown' isn't defined in the schema", messages); + Assert.All(result!.Issues, i => Assert.Equal(OpenApiValidationIssueLocation.Request, i.Location)); + } + + [Fact] + public async Task ReportsMissingRequiredRequestBodyAndUndocumentedContentType() + { + var plugin = await CreatePluginAsync(); + + var missing = plugin.Validate(TestExchange.Request("POST", "https://api.contoso.com/v1/users") + .WithResponse(HttpStatusCode.Created).Session); + var xml = plugin.Validate(TestExchange.Request("POST", "https://api.contoso.com/v1/users", + headers: [("Content-Type", "application/xml")], body: "") + .WithResponse(HttpStatusCode.Created).Session); + + Assert.Equal("Missing required request body", Assert.Single(Messages(missing))); + Assert.Contains("Content type 'application/xml' isn't documented", Assert.Single(Messages(xml)), StringComparison.Ordinal); + } + + [Fact] + public async Task ReportsResponseIssues() + { + var plugin = await CreatePluginAsync(); + var headers = new[] { ("x-tenant", "contoso") }; + + var undocumentedStatus = plugin.Validate(Get("https://api.contoso.com/v1/users", HttpStatusCode.InternalServerError, "{}", headers).Session); + var badBody = plugin.Validate(Get("https://api.contoso.com/v1/users", HttpStatusCode.OK, """[ { "id": "1" } ]""", headers, + responseHeaders: [("x-total-count", "1")]).Session); + var missingHeader = plugin.Validate(Get("https://api.contoso.com/v1/users", HttpStatusCode.OK, "[]", headers).Session); + var range = plugin.Validate(Get("https://api.contoso.com/v1/users", HttpStatusCode.NotFound, """{ "message": "nope" }""", headers).Session); + + Assert.Contains("Status code 500 isn't documented", Assert.Single(Messages(undocumentedStatus)), StringComparison.Ordinal); + var badBodyMessages = Messages(badBody); + Assert.Contains("$[0].id: Expected integer but got string", badBodyMessages); + Assert.Contains("$[0]: Missing required property 'name'", badBodyMessages); + Assert.Contains("Missing required response header 'x-total-count'", Assert.Single(Messages(missingHeader)), StringComparison.Ordinal); + Assert.Empty(range!.Issues); + } + + [Fact] + public async Task ValidatesOneOf() + { + var plugin = await CreatePluginAsync(); + + var valid = plugin.Validate(Get("https://api.contoso.com/v1/pets", HttpStatusCode.OK, """[ { "meows": true }, { "barks": false } ]""").Session); + var invalid = plugin.Validate(Get("https://api.contoso.com/v1/pets", HttpStatusCode.OK, """[ { "quacks": true } ]""").Session); + + Assert.Empty(valid!.Issues); + Assert.Contains("$[0]: Value doesn't match any of the schemas in 'oneOf'", Messages(invalid)); + } + + [Fact] + public async Task IgnoresRequestsNotMatchingAnySpec() + { + var plugin = await CreatePluginAsync(); + + Assert.Null(plugin.Validate(Get("https://api.contoso.com/v2/users", HttpStatusCode.OK, "[]").Session)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StoresReportOnRecordingStop(bool reportUnmatchedRequests) + { + var plugin = await CreatePluginAsync($$"""{ "specsFolder": ".", "reportUnmatchedRequests": {{(reportUnmatchedRequests ? "true" : "false")}} }"""); + var args = Recording([ + Get("https://api.contoso.com/v1/users/1", HttpStatusCode.OK, """{ "id": 1, "name": "Alex" }""").AsRequestLog(MessageType.InterceptedResponse), + Get("https://api.contoso.com/v1/users/1", HttpStatusCode.OK, """{ "id": 1 }""").AsRequestLog(MessageType.InterceptedResponse), + Get("https://api.contoso.com/other", HttpStatusCode.OK, "{}").AsRequestLog(MessageType.InterceptedResponse), + ]); + + await plugin.AfterRecordingStopAsync(args, CancellationToken.None); + + var report = Assert.IsType( + ((Dictionary)args.GlobalData[ProxyUtils.ReportsKey])[plugin.Name]); + Assert.Equal(2, report.ValidatedRequests); + var result = Assert.Single(report.Results); + Assert.Equal("GET /users/{id}", result.Operation); + if (reportUnmatchedRequests) + { + Assert.Equal(["GET https://api.contoso.com/other"], report.UnmatchedRequests); + } + else + { + Assert.Null(report.UnmatchedRequests); + } + Assert.Contains("Missing required property 'name'", report.ToMarkdown(), StringComparison.Ordinal); + Assert.Contains("Missing required property 'name'", report.ToPlainText(), StringComparison.Ordinal); + } + + [Fact] + public async Task ReportsSkippedValidations() + { + var plugin = await CreatePluginAsync(); + var args = Recording([ + Get("https://api.contoso.com/v1/users/1", HttpStatusCode.OK, """{ "id": 1, "name": "Alex" }""").AsRequestLog(MessageType.InterceptedResponse), + Get("https://api.contoso.com/v1/users/2", HttpStatusCode.OK, $$"""{ "id": 2, "name": "{{new string('a', 5 * 1024 * 1024)}}" }""").AsRequestLog(MessageType.InterceptedResponse), + ]); + + await plugin.AfterRecordingStopAsync(args, CancellationToken.None); + + var report = Assert.IsType( + ((Dictionary)args.GlobalData[ProxyUtils.ReportsKey])[plugin.Name]); + Assert.Equal(2, report.ValidatedRequests); + Assert.Empty(report.Results); + var skipped = Assert.Single(report.SkippedValidations!); + Assert.StartsWith("GET https://api.contoso.com/v1/users/2: Skipped validating the response body", skipped, StringComparison.Ordinal); + var markdown = report.ToMarkdown()!; + Assert.DoesNotContain("All validated requests", markdown, StringComparison.Ordinal); + Assert.Contains("## Skipped validations", markdown, StringComparison.Ordinal); + Assert.Contains(skipped, report.ToPlainText(), StringComparison.Ordinal); + } + + [Fact] + public async Task LoadsYamlOpenApi31SpecsFromFolder() + { + var specsDir = Directory.CreateDirectory(Path.Combine(_dir.FullName, "specs", "nested")); + await File.WriteAllTextAsync(Path.Combine(specsDir.FullName, "fabrikam.yaml"), """ + openapi: 3.1.0 + info: + title: Fabrikam + version: '1.0' + servers: + - url: https://{region}.fabrikam.com + variables: + region: + default: eu + paths: + /items: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + properties: + name: + type: [string, 'null'] + """); + var plugin = await CreatePluginAsync("""{ "specsFolder": "specs" }"""); + + var valid = plugin.Validate(TestExchange.Request("GET", "https://eu.fabrikam.com/items") + .WithResponse(HttpStatusCode.OK, headers: [("Content-Type", "application/json; charset=utf-8")], body: """{ "name": null }""").Session); + var invalid = plugin.Validate(TestExchange.Request("GET", "https://eu.fabrikam.com/items") + .WithResponse(HttpStatusCode.OK, headers: [("Content-Type", "application/json")], body: """{ "name": 1 }""").Session); + + Assert.Equal("GET /items", valid?.Operation); + Assert.Empty(valid!.Issues); + Assert.Contains("$.name: Expected string or null but got integer", Messages(invalid)); + } + + [Fact] + public async Task ThrowsWhenNoSpecsConfigured() + { + _ = await Assert.ThrowsAsync(() => CreatePluginAsync("{}")); + } + + private async Task CreatePluginWithSpecsAsync(params (string Name, string Content)[] specs) + { + foreach (var (name, content) in specs) + { + await File.WriteAllTextAsync(Path.Combine(_dir.FullName, name), content); + } + var files = string.Join(", ", specs.Select(s => $"\"{s.Name}\"")); + return await CreatePluginAsync($$"""{ "specFiles": [ {{files}} ] }"""); + } + + private static TestExchange Call(string method, string url, IEnumerable<(string, string)>? requestHeaders = null) => + TestExchange.Request(method, url, headers: requestHeaders).WithResponse(HttpStatusCode.NoContent); + + [Fact] + public async Task FindsMethodAcrossSpecsSharingServerAndPath() + { + var plugin = await CreatePluginWithSpecsAsync( + ("read.yaml", """ + openapi: 3.0.3 + info: { title: Read, version: '1.0' } + servers: + - url: https://api.fabrikam.com + paths: + /users: + get: + responses: { '204': { description: ok } } + """), + ("write.yaml", """ + openapi: 3.0.3 + info: { title: Write, version: '1.0' } + servers: + - url: https://api.fabrikam.com + paths: + /users: + post: + responses: { '204': { description: ok } } + """)); + + var post = plugin.Validate(Call("POST", "https://api.fabrikam.com/users").Session); + var delete = plugin.Validate(Call("DELETE", "https://api.fabrikam.com/users").Session); + + Assert.Equal("POST /users", post?.Operation); + Assert.EndsWith("write.yaml", post!.SpecFile, StringComparison.Ordinal); + Assert.Empty(post.Issues); + Assert.Contains("Method DELETE isn't documented", Assert.Single(Messages(delete)), StringComparison.Ordinal); + } + + [Fact] + public async Task MatchesPathAndOperationLevelServers() + { + var plugin = await CreatePluginWithSpecsAsync(("servers.yaml", """ + openapi: 3.0.3 + info: { title: Servers, version: '1.0' } + servers: + - url: https://api.fabrikam.com + paths: + /users: + get: + responses: { '204': { description: ok } } + post: + servers: + - url: https://write.fabrikam.com + responses: { '204': { description: ok } } + /files: + servers: + - url: https://files.fabrikam.com + get: + responses: { '204': { description: ok } } + """)); + + Assert.Empty(plugin.Validate(Call("GET", "https://api.fabrikam.com/users").Session)!.Issues); + Assert.Empty(plugin.Validate(Call("POST", "https://write.fabrikam.com/users").Session)!.Issues); + Assert.Empty(plugin.Validate(Call("GET", "https://files.fabrikam.com/files").Session)!.Issues); + Assert.Contains("Method POST isn't documented", Assert.Single(Messages(plugin.Validate(Call("POST", "https://api.fabrikam.com/users").Session))), StringComparison.Ordinal); + Assert.Contains("Path '/files' isn't documented", Assert.Single(Messages(plugin.Validate(Call("GET", "https://api.fabrikam.com/files").Session))), StringComparison.Ordinal); + } + + [Fact] + public async Task MatchesPathsCaseSensitivelyAndStrictlyOnTrailingSlash() + { + var plugin = await CreatePluginAsync(); + + Assert.Null(plugin.Validate(Get("https://API.contoso.com/V1/users/me", HttpStatusCode.OK, "{}").Session)); + Assert.Equal("GET /users/me", plugin.Validate(Get("https://API.contoso.com/v1/users/me", HttpStatusCode.OK, """{ "id": 1, "name": "Alex" }""").Session)?.Operation); + Assert.Contains("Path '/Users/me' isn't documented", Assert.Single(Messages(plugin.Validate(Get("https://api.contoso.com/v1/Users/me", HttpStatusCode.OK, "{}").Session))), StringComparison.Ordinal); + Assert.Contains("Path '/users/me/' isn't documented", Assert.Single(Messages(plugin.Validate(Get("https://api.contoso.com/v1/users/me/", HttpStatusCode.OK, "{}").Session))), StringComparison.Ordinal); + } + + [Fact] + public async Task DecodesParametersAccordingToStyleAndExplode() + { + var plugin = await CreatePluginWithSpecsAsync(("styles.yaml", """ + openapi: 3.0.3 + info: { title: Styles, version: '1.0' } + servers: + - url: https://api.fabrikam.com + paths: + /items/{ids}: + get: + parameters: + - { name: ids, in: path, required: true, style: label, schema: { type: array, items: { type: integer } } } + - { name: form, in: query, schema: { type: array, items: { type: integer } } } + - { name: csv, in: query, explode: false, schema: { type: array, items: { type: integer } } } + - { name: pipes, in: query, style: pipeDelimited, explode: false, schema: { type: array, items: { type: integer } } } + - { name: x-ids, in: header, schema: { type: array, items: { type: integer } } } + - { name: filter, in: query, required: true, style: deepObject, schema: { type: object } } + - { name: score, in: query, schema: { type: number } } + responses: { '204': { description: ok } } + """)); + + var valid = plugin.Validate(Call("GET", + "https://api.fabrikam.com/items/.1,2?form=1&form=2&csv=1,2&pipes=1%7C2&filter[name]=Alex&score=1.5", + [("x-ids", "1, 2")]).Session); + var invalid = plugin.Validate(Call("GET", + "https://api.fabrikam.com/items/.1,2?form=1,2&filter[name]=Alex&score=NaN").Session); + + Assert.Empty(valid!.Issues); + var messages = Messages(invalid); + Assert.Contains(messages, m => m.StartsWith("form[0]:", StringComparison.Ordinal) && m.Contains("Expected integer", StringComparison.Ordinal)); + Assert.Contains(messages, m => m.StartsWith("score:", StringComparison.Ordinal) && m.Contains("Expected number", StringComparison.Ordinal)); + Assert.Equal(2, messages.Length); + } + + [Fact] + public async Task HandlesObjectQueryParameters() + { + var plugin = await CreatePluginWithSpecsAsync(("objects.yaml", """ + openapi: 3.0.3 + info: { title: Objects, version: '1.0' } + servers: + - url: https://api.fabrikam.com + paths: + /items: + get: + parameters: + - name: filter + in: query + required: true + schema: { type: object, properties: { status: { type: string }, owner: { type: string } } } + - { name: top, in: query, schema: { type: integer } } + responses: { '204': { description: ok } } + """)); + + var exploded = plugin.Validate(Call("GET", "https://api.fabrikam.com/items?status=active&owner=me&top=5").Session); + var bracketed = plugin.Validate(Call("GET", "https://api.fabrikam.com/items?status=active&top[foo]=bar").Session); + var missing = plugin.Validate(Call("GET", "https://api.fabrikam.com/items?top=5").Session); + + Assert.Empty(exploded!.Issues); + Assert.Equal("top[foo]: Query parameter 'top[foo]' isn't documented", Assert.Single(Messages(bracketed))); + Assert.Equal("filter: Missing required query parameter 'filter'", Assert.Single(Messages(missing))); + } + + [Fact] + public async Task CoercesParametersUsingComposedSchemaTypes() + { + var plugin = await CreatePluginWithSpecsAsync(("composed.yaml", """ + openapi: 3.0.3 + info: { title: Composed, version: '1.0' } + servers: + - url: https://api.fabrikam.com + paths: + /items: + get: + parameters: + - { name: count, in: query, schema: { allOf: [ { type: integer } ] } } + - { name: ids, in: query, schema: { allOf: [ { type: array, items: { allOf: [ { type: integer } ] } } ] } } + responses: { '204': { description: ok } } + """)); + + var valid = plugin.Validate(Call("GET", "https://api.fabrikam.com/items?count=42&ids=1&ids=2").Session); + var invalid = plugin.Validate(Call("GET", "https://api.fabrikam.com/items?count=abc").Session); + + Assert.Empty(valid!.Issues); + Assert.Contains("Expected integer", Assert.Single(Messages(invalid)), StringComparison.Ordinal); + } + + [Fact] + public async Task InspectsRepeatedParameterSchemaReferencesEfficiently() + { + var schemas = string.Join(",\n", Enumerable.Range(0, 30).Select(i => + $$""" "S{{i}}": { "allOf": [ { "$ref": "#/components/schemas/S{{i + 1}}" }, { "$ref": "#/components/schemas/S{{i + 1}}" } ] }""")); + var plugin = await CreatePluginWithSpecsAsync(("wideparams.json", $$""" + { + "openapi": "3.1.0", + "info": { "title": "Wide", "version": "1.0" }, + "servers": [ { "url": "https://api.fabrikam.com" } ], + "paths": { + "/wide": { + "get": { + "parameters": [ + { "name": "count", "in": "query", "schema": { "$ref": "#/components/schemas/S0" } }, + { "name": "ids", "in": "query", "schema": { "type": "array", "allOf": [ { "$ref": "#/components/schemas/S0" } ] } }, + { "name": "filter", "in": "query", "schema": { "type": "object", "allOf": [ { "$ref": "#/components/schemas/S0" } ] } } + ], + "responses": { "204": { "description": "ok" } } + } + } + }, + "components": { + "schemas": { + {{schemas}}, + "S30": { "properties": { "status": { "type": "string" } } } + } + } + } + """)); + + var result = plugin.Validate(Call("GET", "https://api.fabrikam.com/wide?count=1&ids=1&status=active").Session); + + Assert.Empty(result!.Issues); + } + + [Fact] + public async Task AcceptsParameterValuesValidAsStrings() + { + var plugin = await CreatePluginWithSpecsAsync(("strings.yaml", """ + openapi: 3.0.3 + info: { title: Strings, version: '1.0' } + servers: + - url: https://api.fabrikam.com + paths: + /items: + get: + parameters: + - { name: x, in: query, schema: { anyOf: [ { type: integer, enum: [1, 2] }, { type: string } ] } } + - { name: n, in: query, schema: { type: integer, enum: [1, 2] } } + responses: { '204': { description: ok } } + """)); + + var valid = plugin.Validate(Call("GET", "https://api.fabrikam.com/items?x=42&n=1").Session); + var invalid = plugin.Validate(Call("GET", "https://api.fabrikam.com/items?n=42").Session); + + Assert.Empty(valid!.Issues); + Assert.Contains("isn't one of the allowed values", Assert.Single(Messages(invalid)), StringComparison.Ordinal); + } + + [Fact] + public async Task ReportsInvalidJsonForMediaTypesWithoutSchema() + { + var plugin = await CreatePluginWithSpecsAsync(("noschema.yaml", """ + openapi: 3.0.3 + info: { title: No schema, version: '1.0' } + servers: + - url: https://api.fabrikam.com + paths: + /items: + get: + responses: + '200': { description: ok, content: { application/json: {} } } + """)); + + var valid = plugin.Validate(TestExchange.Request("GET", "https://api.fabrikam.com/items") + .WithResponse(HttpStatusCode.OK, headers: [("Content-Type", "application/json")], body: "{}").Session); + var invalid = plugin.Validate(TestExchange.Request("GET", "https://api.fabrikam.com/items") + .WithResponse(HttpStatusCode.OK, headers: [("Content-Type", "application/json")], body: "{").Session); + + Assert.Empty(valid!.Issues); + Assert.Contains("isn't valid JSON", Assert.Single(Messages(invalid)), StringComparison.Ordinal); + } + + [Fact] + public async Task MatchesServersWithExplicitDefaultPorts() + { + var plugin = await CreatePluginWithSpecsAsync(("ports.yaml", """ + openapi: 3.0.3 + info: { title: Ports, version: '1.0' } + servers: + - url: https://api.contoso.com:443/v1 + - url: http://api.contoso.com:80/v1 + paths: + /items: + get: + responses: { '204': { description: ok } } + """)); + + Assert.NotNull(plugin.Validate(Call("GET", "https://api.contoso.com/v1/items").Session)); + Assert.NotNull(plugin.Validate(Call("GET", "http://api.contoso.com/v1/items").Session)); + } + + [Fact] + public async Task DistinguishesDottedPropertyNamesFromNestedProperties() + { + var plugin = await CreatePluginWithSpecsAsync(("dotted.yaml", """ + openapi: 3.0.3 + info: { title: Dotted, version: '1.0' } + servers: + - url: https://api.fabrikam.com + paths: + /items: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + properties: + a: { type: object, properties: { b: { allOf: [ { $ref: '#/components/schemas/Str' } ] } } } + a.b: { allOf: [ { $ref: '#/components/schemas/Str' } ] } + components: + schemas: + Str: { type: string } + """)); + + var result = plugin.Validate(TestExchange.Request("GET", "https://api.fabrikam.com/items") + .WithResponse(HttpStatusCode.OK, headers: [("Content-Type", "application/json")], body: """{"a":{"b":"ok"},"a.b":123}""").Session); + + Assert.Equal("$[\"a.b\"]: Expected string but got integer", Assert.Single(Messages(result))); + } + + [Fact] + public async Task HintsWhenAllOfBranchRejectsPropertyDeclaredInSibling() + { + var plugin = await CreatePluginWithSpecsAsync(("allof.yaml", """ + openapi: 3.0.3 + info: { title: AllOf, version: '1.0' } + servers: + - url: https://api.fabrikam.com + paths: + /items: + get: + responses: + '200': + description: ok + content: + application/json: + schema: + allOf: + - { type: object, properties: { id: { type: integer } }, additionalProperties: false } + - { type: object, properties: { name: { type: string } } } + """)); + + var result = plugin.Validate(TestExchange.Request("GET", "https://api.fabrikam.com/items") + .WithResponse(HttpStatusCode.OK, headers: [("Content-Type", "application/json")], body: """{"id":1,"name":"Alex","other":true}""").Session); + + var messages = Messages(result); + Assert.Equal(2, messages.Length); + Assert.Contains("$.name: Property 'name' isn't defined in the schema. The property is declared in a sibling allOf schema; additionalProperties: false applies per subschema", messages); + Assert.Contains("$.other: Property 'other' isn't defined in the schema", messages); + } + + [Fact] + public async Task SkipsValidatingLargeBodies() + { + var plugin = await CreatePluginWithSpecsAsync(("large.yaml", """ + openapi: 3.0.3 + info: { title: Large, version: '1.0' } + servers: + - url: https://api.fabrikam.com + paths: + /items: + get: + responses: + '200': { description: ok, content: { application/json: { schema: { type: integer } } } } + """)); + + var large = TestExchange.Request("GET", "https://api.fabrikam.com/items") + .WithResponse(HttpStatusCode.OK, headers: [("Content-Type", "application/json")], + body: $"\"{new string('a', 5 * 1024 * 1024)}\"").Session; + var small = TestExchange.Request("GET", "https://api.fabrikam.com/items") + .WithResponse(HttpStatusCode.OK, headers: [("Content-Type", "application/json")], body: "\"a\"").Session; + + Assert.Empty(plugin.Validate(large)!.Issues); + Assert.Contains("exceeds the maximum of 5,242,880 bytes", Assert.Single(plugin.GetNotes(large)), StringComparison.Ordinal); + Assert.Contains("Expected integer", Assert.Single(Messages(plugin.Validate(small))), StringComparison.Ordinal); + Assert.Empty(plugin.GetNotes(small)); + } + + [Fact] + public async Task DoesNotReuseDepthLimitedResultsAtShallowerDepths() + { + // the chain reaches Str exactly beyond the depth limit, while the + // second anyOf branch reaches it at a shallow depth + var schemas = string.Join(",\n", Enumerable.Range(0, 63).Select(i => + $$""" "S{{i}}": { "allOf": [ { "$ref": "#/components/schemas/S{{i + 1}}" } ] }""")); + var plugin = await CreatePluginWithSpecsAsync(("depthcache.json", $$""" + { + "openapi": "3.0.3", + "info": { "title": "Depth cache", "version": "1.0" }, + "servers": [ { "url": "https://api.fabrikam.com" } ], + "paths": { + "/items": { + "get": { + "responses": { + "200": { "description": "ok", "content": { "application/json": { "schema": { + "anyOf": [ { "$ref": "#/components/schemas/S0" }, { "$ref": "#/components/schemas/Str" } ] + } } } } + } + } + } + }, + "components": { + "schemas": { + {{schemas}}, + "S63": { "allOf": [ { "$ref": "#/components/schemas/Str" } ] }, + "Str": { "type": "string" } + } + } + } + """)); + + var result = plugin.Validate(TestExchange.Request("GET", "https://api.fabrikam.com/items") + .WithResponse(HttpStatusCode.OK, headers: [("Content-Type", "application/json")], body: "\"ok\"").Session); + + Assert.Empty(result!.Issues); + } + + [Fact] + public async Task ReportsMethodNotDocumentedOnMoreSpecificLiteralPath() + { + var plugin = await CreatePluginWithSpecsAsync(("literal.yaml", """ + openapi: 3.0.3 + info: { title: Literal, version: '1.0' } + servers: + - url: https://api.fabrikam.com + paths: + /users/me: + get: + responses: { '204': { description: ok } } + /users/{id}: + post: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + responses: { '204': { description: ok } } + """)); + + var result = plugin.Validate(Call("POST", "https://api.fabrikam.com/users/me").Session); + + Assert.Equal("Method POST isn't documented for path '/users/me'", Assert.Single(Messages(result))); + } + + [Fact] + public async Task RejectsNullNotInEnumForOpenApi31TypeArrays() + { + var plugin = await CreatePluginWithSpecsAsync(("nullenum.yaml", """ + openapi: 3.1.0 + info: { title: Null enum, version: '1.0' } + servers: + - url: https://api.fabrikam.com + paths: + /items: + get: + responses: + '200': + description: ok + content: + application/json: + schema: { type: [string, 'null'], enum: [active, inactive] } + """)); + + var result = plugin.Validate(TestExchange.Request("GET", "https://api.fabrikam.com/items") + .WithResponse(HttpStatusCode.OK, headers: [("Content-Type", "application/json")], body: "null").Session); + + Assert.Contains("isn't one of the allowed values", Assert.Single(Messages(result)), StringComparison.Ordinal); + } + + [Fact] + public async Task ExemptsReadOnlyPropertiesDeclaredInComposedSchemas() + { + var plugin = await CreatePluginWithSpecsAsync(("readonly.yaml", """ + openapi: 3.0.3 + info: { title: Read-only, version: '1.0' } + servers: + - url: https://api.fabrikam.com + paths: + /items: + post: + requestBody: + content: + application/json: + schema: + required: [id, name] + allOf: + - $ref: '#/components/schemas/Base' + - { type: object, properties: { name: { type: string } } } + responses: { '204': { description: ok } } + components: + schemas: + Base: { type: object, properties: { id: { type: integer, readOnly: true } } } + """)); + + var valid = plugin.Validate(TestExchange.Request("POST", "https://api.fabrikam.com/items", + headers: [("Content-Type", "application/json")], body: """{"name":"Alex"}""").WithResponse(HttpStatusCode.NoContent).Session); + var invalid = plugin.Validate(TestExchange.Request("POST", "https://api.fabrikam.com/items", + headers: [("Content-Type", "application/json")], body: """{"id":1}""").WithResponse(HttpStatusCode.NoContent).Session); + + Assert.Empty(valid!.Issues); + Assert.Equal("$: Missing required property 'name'", Assert.Single(Messages(invalid))); + } + + [Fact] + public async Task ReportsSchemasExceedingMaxDepth() + { + var schemas = string.Join(",\n", Enumerable.Range(0, 70).Select(i => + $$""" "S{{i}}": { "allOf": [ { "$ref": "#/components/schemas/S{{i + 1}}" } ] }""")); + var plugin = await CreatePluginWithSpecsAsync(("deep.json", $$""" + { + "openapi": "3.0.3", + "info": { "title": "Deep", "version": "1.0" }, + "servers": [ { "url": "https://api.fabrikam.com" } ], + "paths": { + "/deep": { + "get": { + "responses": { + "200": { "description": "ok", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/S0" } } } } + } + } + } + }, + "components": { + "schemas": { + {{schemas}}, + "S70": { "type": "string" } + } + } + } + """)); + + var result = plugin.Validate(TestExchange.Request("GET", "https://api.fabrikam.com/deep") + .WithResponse(HttpStatusCode.OK, headers: [("Content-Type", "application/json")], body: "1").Session); + + Assert.Contains(Messages(result), m => m.Contains("supported maximum", StringComparison.Ordinal)); + } + + [Fact] + public async Task ValidatesRepeatedSchemaReferencesEfficiently() + { + var schemas = string.Join(",\n", Enumerable.Range(0, 30).Select(i => + $$""" "S{{i}}": { "allOf": [ { "$ref": "#/components/schemas/S{{i + 1}}" }, { "$ref": "#/components/schemas/S{{i + 1}}" } ] }""")); + var plugin = await CreatePluginWithSpecsAsync(("wide.json", $$""" + { + "openapi": "3.0.3", + "info": { "title": "Wide", "version": "1.0" }, + "servers": [ { "url": "https://api.fabrikam.com" } ], + "paths": { + "/wide": { + "get": { + "responses": { + "200": { "description": "ok", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/S0" } } } } + } + } + } + }, + "components": { + "schemas": { + {{schemas}}, + "S30": { "type": "string" } + } + } + } + """)); + + var valid = plugin.Validate(TestExchange.Request("GET", "https://api.fabrikam.com/wide") + .WithResponse(HttpStatusCode.OK, headers: [("Content-Type", "application/json")], body: "\"ok\"").Session); + var invalid = plugin.Validate(TestExchange.Request("GET", "https://api.fabrikam.com/wide") + .WithResponse(HttpStatusCode.OK, headers: [("Content-Type", "application/json")], body: "1").Session); + + Assert.Empty(valid!.Issues); + Assert.Equal("$: Expected string but got integer", Assert.Single(Messages(invalid))); + } + + [Fact] + public async Task ValidatesLargeInvalidArraysInLinearTime() + { + var plugin = await CreatePluginWithSpecsAsync(("array.yaml", """ + openapi: 3.0.3 + info: { title: Array, version: "1.0" } + servers: + - url: https://api.fabrikam.com + paths: + /items: + get: + responses: + "200": + description: ok + content: + application/json: + schema: + type: array + items: + allOf: + - type: string + """)); + const int count = 20_000; + var body = $"[{string.Join(',', Enumerable.Repeat("1", count))}]"; + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var result = plugin.Validate(TestExchange.Request("GET", "https://api.fabrikam.com/items") + .WithResponse(HttpStatusCode.OK, headers: [("Content-Type", "application/json")], body: body).Session); + stopwatch.Stop(); + + Assert.Equal(count, result!.Issues.Count()); + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(5), $"Validation took {stopwatch.Elapsed}"); + } + + [Fact] + public async Task WarnsAboutSpecErrors() + { + await File.WriteAllTextAsync(Path.Combine(_dir.FullName, "broken.yaml"), """ + openapi: 3.0.3 + info: { title: Broken, version: '1.0' } + servers: + - url: https://api.fabrikam.com + paths: + /items: + get: + parameters: + - { name: id, in: nowhere, schema: { type: integer } } + responses: { '204': { description: ok } } + """); + var logger = new WarningLogger(); + + var plugin = await CreatePluginAsync("""{ "specFiles": [ "broken.yaml" ] }""", logger); + + Assert.NotNull(plugin.Validate(Call("GET", "https://api.fabrikam.com/items").Session)); + Assert.Contains(logger.Warnings, w => w.Contains("broken.yaml' has errors", StringComparison.Ordinal)); + } + + private sealed class WarningLogger : ILogger + { + public List Warnings { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (logLevel == LogLevel.Warning) + { + Warnings.Add(formatter(state, exception)); + } + } + } + + private const string DiscriminatorSpec = """ + openapi: 3.0.3 + info: { title: Pets, version: '1.0' } + servers: + - url: https://api.fabrikam.com + paths: + /pets: + post: + requestBody: + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + - $ref: '#/components/schemas/Lizard' + discriminator: + propertyName: petType + mapping: + doggo: '#/components/schemas/Dog' + responses: + '204': { description: ok } + components: + schemas: + Cat: + type: object + required: [ petType ] + properties: { petType: { type: string }, name: { type: string }, meows: { type: boolean } } + Dog: + type: object + required: [ petType ] + properties: { petType: { type: string }, name: { type: string }, barks: { type: boolean } } + Lizard: + type: object + required: [ petType ] + properties: { petType: { type: string }, name: { type: string } } + """; + + private static TestExchange PostPet(string body) => + TestExchange.Request("POST", "https://api.fabrikam.com/pets", + headers: [("Content-Type", "application/json")], body: body) + .WithResponse(HttpStatusCode.NoContent); + + [Fact] + public async Task UsesDiscriminatorToSelectOneOfBranch() + { + var plugin = await CreatePluginWithSpecsAsync(("pets.yaml", DiscriminatorSpec)); + + // without the discriminator, these would match all overlapping branches + var implicitName = plugin.Validate(PostPet("""{ "petType": "Cat", "name": "Tom" }""").Session); + var mapped = plugin.Validate(PostPet("""{ "petType": "doggo", "name": "Rex" }""").Session); + var implicitNameWithMapping = plugin.Validate(PostPet("""{ "petType": "Dog", "name": "Rex" }""").Session); + var invalidBranch = plugin.Validate(PostPet("""{ "petType": "doggo", "barks": "loud" }""").Session); + + Assert.Empty(implicitName!.Issues); + Assert.Empty(mapped!.Issues); + Assert.Empty(implicitNameWithMapping!.Issues); + Assert.Equal("$.barks: Expected boolean but got string", Assert.Single(Messages(invalidBranch))); + } + + [Fact] + public async Task ReportsMissingOrUnknownDiscriminatorValues() + { + var plugin = await CreatePluginWithSpecsAsync(("pets.yaml", DiscriminatorSpec)); + + var missing = plugin.Validate(PostPet("""{ "name": "Tom" }""").Session); + var unknown = plugin.Validate(PostPet("""{ "petType": "Fish" }""").Session); + var notString = plugin.Validate(PostPet("""{ "petType": 1 }""").Session); + + Assert.Equal("$: Missing discriminator property 'petType' required to select a schema in 'oneOf'", Assert.Single(Messages(missing))); + Assert.Equal("$.petType: Discriminator value 'Fish' doesn't map to any schema in 'oneOf'. Allowed values: 'doggo', 'Cat', 'Dog', 'Lizard'", Assert.Single(Messages(unknown))); + Assert.Equal("$.petType: Discriminator property 'petType' must be a string but got integer", Assert.Single(Messages(notString))); + } + + [Fact] + public async Task ReportsDiscriminatorMappingsOutsideOneOf() + { + var spec = DiscriminatorSpec + .Replace("doggo: '#/components/schemas/Dog'", "doggo: '#/components/schemas/Dog'\n fish: '#/components/schemas/Fish'", StringComparison.Ordinal) + .Replace(" Lizard:", " Fish:\n type: object\n properties: { fins: { type: integer } }\n Lizard:", StringComparison.Ordinal); + var defaultSpec = spec + .Replace("openapi: 3.0.3", "openapi: 3.2.0", StringComparison.Ordinal) + .Replace("propertyName: petType", "propertyName: petType\n defaultMapping: '#/components/schemas/Fish'", StringComparison.Ordinal); + var plugin = await CreatePluginWithSpecsAsync(("pets.yaml", spec)); + + var stale = plugin.Validate(PostPet("""{ "petType": "fish", "fins": 2 }""").Session); + var unknown = plugin.Validate(PostPet("""{ "petType": "Bird" }""").Session); + + Assert.Equal("$.petType: Discriminator value 'fish' maps to schema 'Fish', which isn't one of the schemas in 'oneOf'", Assert.Single(Messages(stale))); + Assert.DoesNotContain("'fish'", Assert.Single(Messages(unknown)), StringComparison.Ordinal); + + plugin = await CreatePluginWithSpecsAsync(("pets-default.yaml", defaultSpec)); + + var missing = plugin.Validate(PostPet("""{ "fins": 2 }""").Session); + + Assert.Equal("$: Discriminator default mapping maps to schema 'Fish', which isn't one of the schemas in 'oneOf'", Assert.Single(Messages(missing))); + } + + [Fact] + public async Task FallsBackToDiscriminatorDefaultMapping() + { + var spec = DiscriminatorSpec + .Replace("openapi: 3.0.3", "openapi: 3.2.0", StringComparison.Ordinal) + .Replace("propertyName: petType", "propertyName: petType\n defaultMapping: '#/components/schemas/Lizard'", StringComparison.Ordinal) + .Replace("Lizard:\n type: object\n required: [ petType ]", "Lizard:\n type: object\n required: [ name ]", StringComparison.Ordinal); + var plugin = await CreatePluginWithSpecsAsync(("pets.yaml", spec)); + + var missing = plugin.Validate(PostPet("""{ "name": "Spike" }""").Session); + var unknown = plugin.Validate(PostPet("""{ "petType": "Gecko" }""").Session); + + Assert.Empty(missing!.Issues); + Assert.Equal("$: Missing required property 'name'", Assert.Single(Messages(unknown))); + } +} diff --git a/DevProxy.Plugins/Reporting/OpenApiValidationPlugin.cs b/DevProxy.Plugins/Reporting/OpenApiValidationPlugin.cs new file mode 100644 index 00000000..65fcb372 --- /dev/null +++ b/DevProxy.Plugins/Reporting/OpenApiValidationPlugin.cs @@ -0,0 +1,986 @@ +// 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 DevProxy.Abstractions.Proxy.Http; +using DevProxy.Abstractions.Utils; +using DevProxy.Plugins.Utils; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.OpenApi; +using Microsoft.OpenApi.Reader; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace DevProxy.Plugins.Reporting; + +public sealed class OpenApiValidationPluginConfiguration +{ + public bool ReportUnmatchedRequests { get; set; } + public IEnumerable? SpecFiles { get; set; } + public string? SpecsFolder { get; set; } +} + +public sealed class OpenApiValidationPlugin( + HttpClient httpClient, + ILogger logger, + ISet urlsToWatch, + IProxyConfiguration proxyConfiguration, + IConfigurationSection pluginConfigurationSection) : + BaseReportingPlugin( + httpClient, + logger, + urlsToWatch, + proxyConfiguration, + pluginConfigurationSection) +{ + // headers that OpenAPI says to ignore when defined as header parameters + private static readonly string[] _ignoredHeaderParameters = ["Accept", "Content-Type", "Authorization"]; + + private const int MaxBodySize = 5 * 1024 * 1024; + + private readonly ConditionalWeakTable _outcomes = []; + private List _specs = []; + + public override string Name => nameof(OpenApiValidationPlugin); + + public override async Task InitializeAsync(InitArgs e, CancellationToken cancellationToken) + { + await base.InitializeAsync(e, cancellationToken); + + var specFiles = new List(); + + foreach (var specFile in Configuration.SpecFiles ?? []) + { + if (string.IsNullOrWhiteSpace(specFile)) + { + continue; + } + + var fullPath = ProxyUtils.GetFullPath(specFile, ProxyConfiguration.ConfigFile); + if (!File.Exists(fullPath)) + { + Enabled = false; + throw new InvalidOperationException($"Spec file '{fullPath}' does not exist."); + } + specFiles.Add(fullPath); + } + + if (!string.IsNullOrWhiteSpace(Configuration.SpecsFolder)) + { + var folderPath = ProxyUtils.GetFullPath(Configuration.SpecsFolder, ProxyConfiguration.ConfigFile); + if (!Directory.Exists(folderPath)) + { + Enabled = false; + throw new InvalidOperationException($"Specs folder '{folderPath}' does not exist."); + } + + specFiles.AddRange(Directory + .EnumerateFiles(folderPath, "*.*", SearchOption.AllDirectories) + .Where(IsSpecFile) + .Order(StringComparer.Ordinal)); + } + + if (Configuration.SpecFiles?.Any() != true && string.IsNullOrWhiteSpace(Configuration.SpecsFolder)) + { + Enabled = false; + throw new InvalidOperationException("Specify at least one OpenAPI spec using specFiles or specsFolder."); + } + + _specs = await LoadSpecsAsync(specFiles.Distinct(StringComparer.Ordinal), cancellationToken); + if (_specs.Count == 0) + { + Logger.LogWarning("No valid OpenAPI specs with absolute server URLs found. Requests won't be validated."); + } + } + + public override Task AfterResponseAsync(ProxyResponseArgs e, CancellationToken cancellationToken) + { + Logger.LogTrace("{Method} called", nameof(AfterResponseAsync)); + + ArgumentNullException.ThrowIfNull(e); + + var loggingContext = new LoggingContext(e.ProxySession); + if (!e.HasRequestUrlMatch(UrlsToWatch)) + { + Logger.LogRequest("URL not matched", MessageType.Skipped, loggingContext); + return Task.CompletedTask; + } + if (IsCorsPreflight(e.ProxySession.Request)) + { + Logger.LogRequest("Skipping CORS preflight request", MessageType.Skipped, loggingContext); + return Task.CompletedTask; + } + + var outcome = GetOrValidate(e.ProxySession); + foreach (var note in outcome.Notes) + { + Logger.LogRequest(note, MessageType.Normal, loggingContext); + } + if (outcome.Result is null) + { + Logger.LogRequest("No matching OpenAPI spec", MessageType.Skipped, loggingContext); + } + else if (outcome.Result.Issues.Any()) + { + foreach (var issue in outcome.Result.Issues) + { + Logger.LogRequest( + $"OpenAPI {issue.Location.ToString().ToLowerInvariant()} validation: {issue}", + MessageType.Warning, + loggingContext); + } + } + else + { + Logger.LogRequest($"Request and response match {outcome.Result.Operation}", MessageType.Processed, loggingContext); + } + + Logger.LogTrace("Left {Name}", nameof(AfterResponseAsync)); + return Task.CompletedTask; + } + + public override Task AfterRecordingStopAsync(RecordingArgs e, CancellationToken cancellationToken) + { + Logger.LogTrace("{Method} called", nameof(AfterRecordingStopAsync)); + + ArgumentNullException.ThrowIfNull(e); + + var sessions = e.RequestLogs + .Where(l => + l.MessageType == MessageType.InterceptedResponse && + l.Context?.Session is not null && + l.Context.Session.HasResponse && + ProxyUtils.MatchesUrlToWatch(UrlsToWatch, l.Context.Session.Request.RequestUri.AbsoluteUri) && + !IsCorsPreflight(l.Context.Session.Request)) + .Select(l => l.Context!.Session) + .Distinct(); + + if (!sessions.Any()) + { + Logger.LogRequest("No requests to process", MessageType.Skipped); + return Task.CompletedTask; + } + + var validated = 0; + var results = new List(); + var unmatched = new List(); + var skipped = new List(); + + foreach (var session in sessions) + { + cancellationToken.ThrowIfCancellationRequested(); + + var outcome = GetOrValidate(session); + if (outcome.Result is null) + { + unmatched.Add($"{session.Request.Method} {session.Request.Url}"); + continue; + } + + validated++; + skipped.AddRange(outcome.Notes.Select(n => $"{session.Request.Method} {session.Request.Url}: {n}")); + if (outcome.Result.Issues.Any()) + { + results.Add(outcome.Result); + } + } + + if (Logger.IsEnabled(LogLevel.Information)) + { + Logger.LogInformation( + "Validated {Validated} request(s) against OpenAPI specs. {WithIssues} request(s) with issues.", + validated, + results.Count); + } + + var report = new OpenApiValidationPluginReport + { + ValidatedRequests = validated, + Results = results, + UnmatchedRequests = Configuration.ReportUnmatchedRequests ? [.. unmatched.Distinct()] : null, + SkippedValidations = skipped.Count > 0 ? skipped : null + }; + StoreReport(report, e); + + Logger.LogTrace("Left {Name}", nameof(AfterRecordingStopAsync)); + return Task.CompletedTask; + } + + internal OpenApiValidationResult? Validate(IProxySession session) => GetOrValidate(session).Result; + + internal IReadOnlyList GetNotes(IProxySession session) => GetOrValidate(session).Notes; + + private ValidationOutcome GetOrValidate(IProxySession session) => + _outcomes.GetValue(session, s => + { + var notes = new List(); + return new ValidationOutcome(ValidateSession(s, notes), notes); + }); + + private OpenApiValidationResult? ValidateSession(IProxySession session, List notes) + { + var request = session.Request; + var response = session.Response; + var requestText = $"{request.Method} {request.Url}"; + int? statusCode = response is null ? null : (int)response.StatusCode; + + var match = FindOperation(request); + if (match is null) + { + return null; + } + + var issues = new List(); + var specFile = match.Spec.FilePath; + + if (match.PathTemplate is null) + { + issues.Add(new() + { + Location = OpenApiValidationIssueLocation.Request, + Message = $"Path '{match.RelativePath}' isn't documented in the spec" + }); + return new() + { + Request = requestText, + StatusCode = statusCode, + SpecFile = specFile, + Issues = issues + }; + } + + if (match.Operation is null) + { + issues.Add(new() + { + Location = OpenApiValidationIssueLocation.Request, + Message = $"Method {request.Method} isn't documented for path '{match.PathTemplate}'" + }); + return new() + { + Request = requestText, + StatusCode = statusCode, + SpecFile = specFile, + Issues = issues + }; + } + + var strict = match.Spec.StrictNullableEnums; + ValidateParameters(request, match, issues); + ValidateRequestBody(request, match.Operation, issues, notes, strict); + if (response is not null) + { + ValidateResponse(response, match.Operation, issues, notes, strict); + } + + return new() + { + Request = requestText, + StatusCode = statusCode, + Operation = $"{request.Method} {match.PathTemplate}", + SpecFile = specFile, + Issues = issues + }; + } + + private OperationMatch? FindOperation(IHttpRequest request) + { + var requestUrl = request.RequestUri.GetLeftPart(UriPartial.Path); + var method = new HttpMethod(request.Method); + OperationMatch? firstPathMatch = null; + OperationMatch? firstServerMatch = null; + + // prefer a spec that documents the requested method; fall back to + // a matching path without the method, and then to a matching server + foreach (var spec in _specs) + { + foreach (var server in spec.Servers) + { + var serverMatch = server.Regex.Match(requestUrl); + if (!serverMatch.Success) + { + continue; + } + + var relativePath = requestUrl[serverMatch.Length..]; + if (relativePath.Length > 0 && relativePath[0] != '/') + { + // server URL matched only part of a path segment + continue; + } + if (relativePath.Length == 0) + { + relativePath = "/"; + } + + // paths are ordered from the most specific, which takes precedence + // per the OpenAPI spec, even if it doesn't document the method + var bestPath = FindPaths(server, relativePath).FirstOrDefault(); + if (bestPath.Path is { } path) + { + if (path.Operations.TryGetValue(method, out var operation)) + { + return new OperationMatch(spec, relativePath, path.Template, path.PathItem, operation, bestPath.PathValues); + } + firstPathMatch ??= new OperationMatch(spec, relativePath, path.Template, path.PathItem, null, bestPath.PathValues); + } + else + { + firstServerMatch ??= new OperationMatch(spec, relativePath, null, null, null, []); + } + } + } + + return firstPathMatch ?? firstServerMatch; + } + + private static IEnumerable<(CompiledPath Path, Dictionary PathValues)> FindPaths(ServerScope server, string relativePath) + { + // paths are ordered so that literal paths are preferred over templated paths + // (e.g. /users/me over /users/{id}), and among templated paths, those with fewer parameters + foreach (var path in server.Paths) + { + var m = path.Regex.Match(relativePath); + if (!m.Success) + { + continue; + } + + var values = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < path.ParameterNames.Count; i++) + { + values[path.ParameterNames[i]] = Uri.UnescapeDataString(m.Groups[i + 1].Value); + } + yield return (path, values); + } + } + + private static void ValidateParameters(IHttpRequest request, OperationMatch match, List issues) + { + var parameters = GetEffectiveParameters(match.PathItem!, match.Operation!); + var query = ParseQueryString(request.RequestUri.Query); + + foreach (var parameter in parameters) + { + if (parameter.Name is null || parameter.In is null) + { + continue; + } + + List? values = null; + switch (parameter.In) + { + case ParameterLocation.Path: + if (match.PathValues.TryGetValue(parameter.Name, out var pathValue)) + { + values = [pathValue]; + } + break; + case ParameterLocation.Query: + if (query.TryGetValue(parameter.Name, out var queryValues)) + { + values = queryValues; + } + else if (GetObjectQueryKeys(parameter).Any(k => query.Keys.Any(k.IsMatch))) + { + // values of deepObject (e.g. filter[name]=value) and exploded + // form object (e.g. name=value) parameters aren't validated + continue; + } + break; + case ParameterLocation.Header: + if (_ignoredHeaderParameters.Contains(parameter.Name, StringComparer.OrdinalIgnoreCase)) + { + continue; + } + var headerValues = request.Headers.GetAll(parameter.Name).Select(h => h.Value).ToList(); + if (headerValues.Count > 0) + { + values = headerValues; + } + break; + default: + // cookie and querystring parameters aren't validated + continue; + } + + var locationName = parameter.In.Value.ToString().ToLowerInvariant(); + if (values is null) + { + if (parameter.Required) + { + issues.Add(new() + { + Location = OpenApiValidationIssueLocation.Request, + Path = parameter.Name, + Message = $"Missing required {locationName} parameter '{parameter.Name}'" + }); + } + continue; + } + + ValidateParameterValue(parameter.Name, $"{locationName} parameter", values, parameter.Schema, + parameter.Style ?? GetDefaultStyle(parameter.In.Value), parameter.Explode, + OpenApiValidationIssueLocation.Request, issues, match.Spec.StrictNullableEnums); + } + + // unknown query parameters + var hasQueryStringParameter = parameters.Any(p => p.In == ParameterLocation.QueryString); + if (hasQueryStringParameter) + { + return; + } + + var queryParameters = parameters + .Where(p => p.In == ParameterLocation.Query && p.Name is not null) + .ToArray(); + var knownQueryKeys = queryParameters + .Select(p => new ObjectQueryKey(p.Name!, false)) + .Concat(queryParameters.SelectMany(GetObjectQueryKeys)) + .ToArray(); + foreach (var name in query.Keys) + { + if (knownQueryKeys.Any(k => k.IsMatch(name))) + { + continue; + } + + issues.Add(new() + { + Location = OpenApiValidationIssueLocation.Request, + Path = name, + Message = $"Query parameter '{name}' isn't documented" + }); + } + } + + private static ParameterStyle GetDefaultStyle(ParameterLocation location) => location switch + { + ParameterLocation.Query or ParameterLocation.Cookie => ParameterStyle.Form, + _ => ParameterStyle.Simple + }; + + private readonly record struct ObjectQueryKey(string Name, bool IsPrefix) + { + public bool IsMatch(string key) => IsPrefix + ? key.StartsWith(Name, StringComparison.Ordinal) + : string.Equals(key, Name, StringComparison.Ordinal); + } + + // Query keys used to serialize object parameters: deepObject parameters + // are sent as filter[name]=value and exploded form objects as name=value + private static IEnumerable GetObjectQueryKeys(IOpenApiParameter parameter) + { + if (parameter.Name is null) + { + return []; + } + + if (parameter.Style == ParameterStyle.DeepObject) + { + return [new($"{parameter.Name}[", true)]; + } + + var type = OpenApiSchemaValidator.GetEffectiveType(parameter.Schema); + if ((parameter.Style ?? ParameterStyle.Form) == ParameterStyle.Form && parameter.Explode && + type is not null && type.Value.HasFlag(JsonSchemaType.Object)) + { + return OpenApiSchemaValidator.GetPropertyNames(parameter.Schema).Select(p => new ObjectQueryKey(p, false)); + } + + return []; + } + + private static void ValidateParameterValue(string name, string kind, List values, IOpenApiSchema? schema, + ParameterStyle style, bool explode, OpenApiValidationIssueLocation location, List issues, + bool strictNullableEnums) + { + if (schema is null || OpenApiSchemaValidator.GetEffectiveType(schema) == JsonSchemaType.Object) + { + return; + } + + var value = OpenApiSchemaValidator.CoerceParameterValue(values, schema, name, style, explode); + var direction = location == OpenApiValidationIssueLocation.Request + ? SchemaValidationDirection.Request + : SchemaValidationDirection.Response; + var errors = OpenApiSchemaValidator.Validate(value, schema, direction, name, strictNullableEnums); + if (errors.Count > 0) + { + // the value might be valid as a string when the schema allows + // multiple types, e.g. anyOf: [{ type: integer, enum: [1] }, { type: string }] + var raw = OpenApiSchemaValidator.CoerceParameterValue(values, schema, name, style, explode, coerce: false); + if (OpenApiSchemaValidator.Validate(raw, schema, direction, name, strictNullableEnums).Count == 0) + { + return; + } + } + + foreach (var error in errors) + { + issues.Add(new() + { + Location = location, + Path = error.Path, + Message = $"Invalid {kind} value '{string.Join(",", values)}': {error.Message}" + }); + } + } + + private static void ValidateRequestBody(IHttpRequest request, OpenApiOperation operation, List issues, List notes, bool strictNullableEnums) + { + var requestBody = operation.RequestBody; + var hasBody = request.Body.Length > 0; + + if (!hasBody) + { + if (requestBody?.Required == true && !request.HasBody) + { + issues.Add(new() + { + Location = OpenApiValidationIssueLocation.Request, + Message = "Missing required request body" + }); + } + return; + } + + if (requestBody is null) + { + issues.Add(new() + { + Location = OpenApiValidationIssueLocation.Request, + Message = "Request body isn't documented for this operation" + }); + return; + } + + ValidateBody(request, requestBody.Content, OpenApiValidationIssueLocation.Request, issues, notes, strictNullableEnums); + } + + private static void ValidateResponse(IHttpResponse response, OpenApiOperation operation, List issues, List notes, bool strictNullableEnums) + { + var statusCode = ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture); + var responses = operation.Responses; + IOpenApiResponse? specResponse = null; + if (responses is not null) + { + specResponse = + responses.FirstOrDefault(r => r.Key == statusCode).Value ?? + responses.FirstOrDefault(r => string.Equals(r.Key, $"{statusCode[0]}XX", StringComparison.OrdinalIgnoreCase)).Value ?? + responses.FirstOrDefault(r => string.Equals(r.Key, "default", StringComparison.OrdinalIgnoreCase)).Value; + } + + if (specResponse is null) + { + issues.Add(new() + { + Location = OpenApiValidationIssueLocation.Response, + Message = $"Status code {statusCode} isn't documented for this operation" + }); + return; + } + + foreach (var (headerName, header) in specResponse.Headers ?? new Dictionary()) + { + if (string.Equals(headerName, "Content-Type", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var values = response.Headers.GetAll(headerName).Select(h => h.Value).ToList(); + if (values.Count == 0) + { + if (header.Required) + { + issues.Add(new() + { + Location = OpenApiValidationIssueLocation.Response, + Path = headerName, + Message = $"Missing required response header '{headerName}'" + }); + } + continue; + } + + ValidateParameterValue(headerName, "response header", values, header.Schema, + header.Style ?? ParameterStyle.Simple, header.Explode, + OpenApiValidationIssueLocation.Response, issues, strictNullableEnums); + } + + if (response.Body.Length == 0) + { + return; + } + + if (specResponse.Content is not { Count: > 0 }) + { + issues.Add(new() + { + Location = OpenApiValidationIssueLocation.Response, + Message = $"Response body isn't documented for status code {statusCode}" + }); + return; + } + + ValidateBody(response, specResponse.Content, OpenApiValidationIssueLocation.Response, issues, notes, strictNullableEnums); + } + + private static void ValidateBody(IHttpMessage message, IDictionary? content, + OpenApiValidationIssueLocation location, List issues, List notes, bool strictNullableEnums) + { + if (content is not { Count: > 0 }) + { + return; + } + + var locationName = location.ToString().ToLowerInvariant(); + var contentType = GetMediaType(message.ContentType); + if (contentType is null) + { + issues.Add(new() + { + Location = location, + Message = $"The {locationName} has a body but no Content-Type header" + }); + return; + } + + var mediaType = FindMediaType(content, contentType); + if (mediaType is null) + { + issues.Add(new() + { + Location = location, + Message = $"Content type '{contentType}' isn't documented. Documented: {string.Join(", ", content.Keys)}" + }); + return; + } + + if (!IsJsonMediaType(contentType)) + { + return; + } + + // validation runs inline while processing the response, so skip + // large bodies to avoid stalling the proxy + if (message.Body.Length > MaxBodySize) + { + notes.Add(string.Create(CultureInfo.InvariantCulture, + $"Skipped validating the {locationName} body because it's {message.Body.Length:N0} bytes, which exceeds the maximum of {MaxBodySize:N0} bytes")); + return; + } + + JsonDocument json; + try + { + json = JsonDocument.Parse(message.Body); + } + catch (JsonException ex) + { + issues.Add(new() + { + Location = location, + Message = $"The {locationName} body isn't valid JSON: {ex.Message}" + }); + return; + } + + using (json) + { + var direction = location == OpenApiValidationIssueLocation.Request + ? SchemaValidationDirection.Request + : SchemaValidationDirection.Response; + foreach (var error in OpenApiSchemaValidator.Validate(json.RootElement, mediaType.Schema, direction, strictNullableEnums: strictNullableEnums)) + { + issues.Add(new() + { + Location = location, + Path = error.Path, + Message = error.Message + }); + } + } + } + + private static IOpenApiMediaType? FindMediaType(IDictionary content, string contentType) + { + var entries = content + .Select(c => (MediaType: GetMediaType(c.Key) ?? c.Key, c.Value)) + .ToArray(); + + var exact = entries.FirstOrDefault(c => string.Equals(c.MediaType, contentType, StringComparison.OrdinalIgnoreCase)); + if (exact.Value is not null) + { + return exact.Value; + } + + var typeWildcard = $"{contentType.Split('/')[0]}/*"; + var wildcard = entries.FirstOrDefault(c => string.Equals(c.MediaType, typeWildcard, StringComparison.OrdinalIgnoreCase)); + if (wildcard.Value is not null) + { + return wildcard.Value; + } + + return entries.FirstOrDefault(c => c.MediaType == "*/*").Value; + } + + private static string? GetMediaType(string? contentType) + { + if (string.IsNullOrWhiteSpace(contentType)) + { + return null; + } + + return contentType.Split(';')[0].Trim().ToLowerInvariant(); + } + + private static bool IsJsonMediaType(string mediaType) => + mediaType == "application/json" || + mediaType.EndsWith("+json", StringComparison.Ordinal); + + private static List GetEffectiveParameters(IOpenApiPathItem pathItem, OpenApiOperation operation) + { + var parameters = new List(); + parameters.AddRange(pathItem.Parameters ?? []); + + // operation-level parameters override path-level parameters with the same name and location + foreach (var parameter in operation.Parameters ?? []) + { + _ = parameters.RemoveAll(p => p.Name == parameter.Name && p.In == parameter.In); + parameters.Add(parameter); + } + + return parameters; + } + + private static Dictionary> ParseQueryString(string query) + { + var result = new Dictionary>(StringComparer.Ordinal); + if (string.IsNullOrEmpty(query)) + { + return result; + } + + foreach (var pair in query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var separatorIndex = pair.IndexOf('=', StringComparison.Ordinal); + var name = Unescape(separatorIndex < 0 ? pair : pair[..separatorIndex]); + var value = separatorIndex < 0 ? string.Empty : Unescape(pair[(separatorIndex + 1)..]); + + if (!result.TryGetValue(name, out var values)) + { + values = []; + result[name] = values; + } + values.Add(value); + } + + return result; + } + + private static string Unescape(string value) => + Uri.UnescapeDataString(value.Replace('+', ' ')); + + private static bool IsCorsPreflight(IHttpRequest request) => + string.Equals(request.Method, "OPTIONS", StringComparison.OrdinalIgnoreCase) && + request.Headers.Contains("Access-Control-Request-Method"); + + private static bool IsSpecFile(string file) + { + var extension = Path.GetExtension(file); + return extension.Equals(".json", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".yaml", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".yml", StringComparison.OrdinalIgnoreCase); + } + + private async Task> LoadSpecsAsync(IEnumerable files, CancellationToken cancellationToken) + { + var specs = new List(); + + foreach (var file in files) + { + cancellationToken.ThrowIfCancellationRequested(); + + Logger.LogDebug("Loading OpenAPI spec from '{File}'...", file); + OpenApiDocument? document; + string? diagnostics; + var strictNullableEnums = false; + try + { + var fileContents = await File.ReadAllTextAsync(file, cancellationToken); + fileContents = ProxyUtils.ReplaceVariables(fileContents, ProxyConfiguration.Env, v => $"{{{v}}}"); + + var settings = new OpenApiReaderSettings(); + settings.AddYamlReader(); + var readResult = OpenApiDocument.Parse(fileContents, null, settings); + document = readResult.Document; + strictNullableEnums = readResult.Diagnostic?.SpecificationVersion >= OpenApiSpecVersion.OpenApi3_1; + diagnostics = readResult.Diagnostic?.Errors is { Count: > 0 } errors + ? string.Join("; ", errors.Select(e => e.ToString())) + : null; + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to load OpenAPI spec from '{File}'", file); + continue; + } + + if (document is null) + { + Logger.LogWarning("File '{File}' isn't a valid OpenAPI spec. Skipping. {Diagnostics}", file, diagnostics); + continue; + } + + if (diagnostics is not null) + { + // the spec was loaded, but parts of it might've been ignored + Logger.LogWarning("OpenAPI spec '{File}' has errors and might not be validated completely: {Diagnostics}", file, diagnostics); + } + + var servers = BuildServerScopes(document, file); + if (servers.Count == 0) + { + Logger.LogWarning("OpenAPI spec '{File}' doesn't define any absolute server URLs. Skipping", file); + continue; + } + + specs.Add(new LoadedSpec(file, servers, strictNullableEnums)); + Logger.LogInformation("Loaded OpenAPI spec '{File}'", file); + } + + return specs; + } + + private List BuildServerScopes(OpenApiDocument document, string file) + { + // each operation is served from its effective servers: operation-level + // servers override path-level servers, which override document-level servers + var scopes = new Dictionary Paths)>(StringComparer.Ordinal); + var skippedServers = new HashSet(StringComparer.Ordinal); + + (Regex Regex, Dictionary Paths)? GetScope(string? url) + { + if (string.IsNullOrWhiteSpace(url) || !url.Contains("://", StringComparison.Ordinal)) + { + if (skippedServers.Add(url ?? string.Empty)) + { + Logger.LogDebug("Skipping server '{ServerUrl}' in '{File}' because it isn't an absolute URL", url, file); + } + return null; + } + + var key = url.TrimEnd('/'); + if (!scopes.TryGetValue(key, out var scope)) + { + scope = (CompileServerUrl(key), new(StringComparer.Ordinal)); + scopes[key] = scope; + } + return scope; + } + + foreach (var (template, pathItem) in document.Paths ?? []) + { + var pathServers = pathItem.Servers is { Count: > 0 } ? pathItem.Servers : document.Servers ?? []; + + // register the path for its servers even if it has no operations + // there, so that requests with undocumented methods are reported + foreach (var server in pathServers) + { + if (GetScope(server.Url) is { } scope) + { + GetOrAddPath(scope.Paths, template, pathItem); + } + } + + foreach (var (method, operation) in pathItem.Operations ?? new Dictionary()) + { + var operationServers = operation.Servers is { Count: > 0 } ? operation.Servers : pathServers; + foreach (var server in operationServers) + { + if (GetScope(server.Url) is { } scope) + { + GetOrAddPath(scope.Paths, template, pathItem).Operations[method] = operation; + } + } + } + } + + // servers without paths still need to be matched so that requests + // to undocumented paths are reported + foreach (var server in document.Servers ?? []) + { + _ = GetScope(server.Url); + } + + return [.. scopes.Values.Select(s => new ServerScope( + s.Regex, + [.. s.Paths.Values.OrderBy(p => p.ParameterNames.Count)]))]; + } + + private static CompiledPath GetOrAddPath(Dictionary paths, string template, IOpenApiPathItem pathItem) + { + if (!paths.TryGetValue(template, out var path)) + { + path = CompilePath(template, pathItem); + paths[template] = path; + } + return path; + } + + private static Regex CompileServerUrl(string url) + { + // scheme and host are case-insensitive, but the base path is case-sensitive + var authorityStart = url.IndexOf("://", StringComparison.Ordinal) + 3; + var pathStart = url.IndexOf('/', authorityStart); + var origin = pathStart < 0 ? url : url[..pathStart]; + // request URLs omit default ports, e.g. https://api.contoso.com:443 -> https://api.contoso.com + origin = Regex.Replace(origin, "^(?:(?https://[^/]*):443|(?http://[^/]*):80)$", "${o}", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + var basePath = pathStart < 0 ? string.Empty : url[pathStart..]; + + static string ToPattern(string value) => + Regex.Replace(Regex.Escape(value), @"\\\{[^}]+\}", "[^/]*"); + + return new Regex($"^(?i:{ToPattern(origin)}){ToPattern(basePath)}", RegexOptions.CultureInvariant); + } + + private static CompiledPath CompilePath(string template, IOpenApiPathItem pathItem) + { + var parameterNames = new List(); + var pattern = Regex.Replace(Regex.Escape(template), @"\\\{([^}]+)\}", m => + { + parameterNames.Add(Regex.Unescape(m.Groups[1].Value)); + return "([^/]+)"; + }); + var regex = new Regex($"^{pattern}$", RegexOptions.CultureInvariant); + return new CompiledPath(template, pathItem, regex, parameterNames, []); + } + + private sealed record CompiledPath( + string Template, + IOpenApiPathItem PathItem, + Regex Regex, + List ParameterNames, + Dictionary Operations); + + private sealed record ServerScope(Regex Regex, List Paths); + + // OAS 3.1 requires null to be listed in enums, unlike OAS 3.0 nullable enums + private sealed record LoadedSpec(string FilePath, List Servers, bool StrictNullableEnums); + + private sealed record OperationMatch( + LoadedSpec Spec, + string RelativePath, + string? PathTemplate, + IOpenApiPathItem? PathItem, + OpenApiOperation? Operation, + Dictionary PathValues); + + private sealed record ValidationOutcome(OpenApiValidationResult? Result, IReadOnlyList Notes); +} diff --git a/DevProxy.Plugins/Reporting/OpenApiValidationPluginReport.cs b/DevProxy.Plugins/Reporting/OpenApiValidationPluginReport.cs new file mode 100644 index 00000000..047c52b4 --- /dev/null +++ b/DevProxy.Plugins/Reporting/OpenApiValidationPluginReport.cs @@ -0,0 +1,185 @@ +// 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 System.Globalization; +using System.Text; + +namespace DevProxy.Plugins.Reporting; + +public enum OpenApiValidationIssueLocation +{ + Request, + Response +} + +public sealed class OpenApiValidationIssue +{ + public required OpenApiValidationIssueLocation Location { get; init; } + public required string Message { get; init; } + /// + /// Location of the issue in the request or response, e.g. the parameter + /// name or a JSON path in the body. Empty when the issue applies to the + /// whole request or response. + /// + public string? Path { get; init; } + + public override string ToString() => + string.IsNullOrEmpty(Path) ? Message : $"{Path}: {Message}"; +} + +public sealed class OpenApiValidationResult +{ + public required string Request { get; init; } + public int? StatusCode { get; init; } + /// + /// Matched operation, e.g. GET /users/{id}. Empty when the request + /// isn't documented in the spec. + /// + public string? Operation { get; init; } + public string? SpecFile { get; init; } + public required IEnumerable Issues { get; init; } +} + +public sealed class OpenApiValidationPluginReport : IMarkdownReport, IPlainTextReport +{ + public required int ValidatedRequests { get; init; } + public required IEnumerable Results { get; init; } + public IEnumerable? UnmatchedRequests { get; init; } + /// + /// Parts of validated requests that weren't validated, e.g. bodies + /// exceeding the maximum supported size, with the reason. + /// + public IEnumerable? SkippedValidations { get; init; } + + public string? ToMarkdown() + { + var sb = new StringBuilder(); + _ = sb.AppendLine("# OpenAPI validation report") + .AppendLine() + .AppendLine(CultureInfo.InvariantCulture, $"- Validated requests: {ValidatedRequests}") + .AppendLine(CultureInfo.InvariantCulture, $"- Requests with issues: {Results.Count()}"); + if (SkippedValidations?.Any() == true) + { + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"- Skipped validations: {SkippedValidations.Count()}"); + } + + if (!Results.Any() && SkippedValidations?.Any() != true) + { + _ = sb.AppendLine() + .AppendLine("✅ All validated requests and responses match their OpenAPI specs."); + } + + foreach (var result in Results) + { + _ = sb.AppendLine() + .AppendLine(CultureInfo.InvariantCulture, $"## {result.Request}") + .AppendLine(); + + if (result.StatusCode is not null) + { + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"- Status code: {result.StatusCode}"); + } + if (!string.IsNullOrEmpty(result.Operation)) + { + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"- Operation: `{result.Operation}`"); + } + if (!string.IsNullOrEmpty(result.SpecFile)) + { + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"- Spec: {result.SpecFile}"); + } + + _ = sb.AppendLine() + .AppendLine("|Location|Path|Issue|") + .AppendLine("|--------|----|-----|"); + foreach (var issue in result.Issues) + { + var issuePath = string.IsNullOrEmpty(issue.Path) ? "" : $"`{issue.Path}`"; + _ = sb.AppendLine(CultureInfo.InvariantCulture, + $"|{issue.Location.ToString().ToLowerInvariant()}|{issuePath}|{EscapeTableCell(issue.Message)}|"); + } + } + + if (SkippedValidations?.Any() == true) + { + _ = sb.AppendLine() + .AppendLine("## Skipped validations") + .AppendLine() + .AppendJoin(Environment.NewLine, SkippedValidations.Select(r => $"- {r}")) + .AppendLine(); + } + + if (UnmatchedRequests?.Any() == true) + { + _ = sb.AppendLine() + .AppendLine("## Requests not matching any OpenAPI spec") + .AppendLine() + .AppendJoin(Environment.NewLine, UnmatchedRequests.Select(r => $"- {r}")) + .AppendLine(); + } + + return sb.ToString(); + } + + public string? ToPlainText() + { + var sb = new StringBuilder(); + _ = sb.AppendLine("OpenAPI validation report") + .AppendLine() + .AppendLine(CultureInfo.InvariantCulture, $"Validated requests: {ValidatedRequests}") + .AppendLine(CultureInfo.InvariantCulture, $"Requests with issues: {Results.Count()}"); + if (SkippedValidations?.Any() == true) + { + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"Skipped validations: {SkippedValidations.Count()}"); + } + + foreach (var result in Results) + { + _ = sb.AppendLine() + .AppendLine(result.Request); + + if (result.StatusCode is not null) + { + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"Status code: {result.StatusCode}"); + } + if (!string.IsNullOrEmpty(result.Operation)) + { + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"Operation: {result.Operation}"); + } + if (!string.IsNullOrEmpty(result.SpecFile)) + { + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"Spec: {result.SpecFile}"); + } + + foreach (var issue in result.Issues) + { + _ = sb.AppendLine(CultureInfo.InvariantCulture, + $"- [{issue.Location.ToString().ToLowerInvariant()}] {issue}"); + } + } + + if (SkippedValidations?.Any() == true) + { + _ = sb.AppendLine() + .AppendLine("Skipped validations:") + .AppendLine() + .AppendJoin(Environment.NewLine, SkippedValidations.Select(r => $"- {r}")) + .AppendLine(); + } + + if (UnmatchedRequests?.Any() == true) + { + _ = sb.AppendLine() + .AppendLine("Requests not matching any OpenAPI spec:") + .AppendLine() + .AppendJoin(Environment.NewLine, UnmatchedRequests.Select(r => $"- {r}")) + .AppendLine(); + } + + return sb.ToString(); + } + + private static string EscapeTableCell(string value) => + value.Replace("|", "\\|", StringComparison.Ordinal); +} diff --git a/DevProxy.Plugins/Utils/OpenApiSchemaValidator.cs b/DevProxy.Plugins/Utils/OpenApiSchemaValidator.cs new file mode 100644 index 00000000..d76f7d3f --- /dev/null +++ b/DevProxy.Plugins/Utils/OpenApiSchemaValidator.cs @@ -0,0 +1,635 @@ +// 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.Utils; + +internal enum SchemaValidationDirection +{ + Request, + Response +} + +/// Name of the property when the error is +/// about a property that isn't allowed by the schema. +internal sealed record SchemaValidationError(string Path, string Message, string? UndefinedProperty = null); + +/// +/// Lightweight validator of JSON values against OpenAPI schemas +/// (Microsoft.OpenApi object model). Supports: type (incl. nullable / type null), +/// required, properties, additionalProperties, items, enum, allOf, anyOf, oneOf +/// (incl. discriminator) and $ref. Other keywords (format, min/max, pattern, etc.) are ignored. +/// +internal static class OpenApiSchemaValidator +{ + private const int MaxDepth = 64; + // bounds total work, e.g. for schemas that reference the same subschema repeatedly + private const int MaxEvaluations = 1_000_000; + + private sealed class ValidationState + { + public int Evaluations { get; set; } + public bool BudgetExceeded { get; set; } + public bool StrictNullableEnums { get; init; } + public Dictionary<(IOpenApiSchema Schema, string Path, int Depth), List> Memo { get; } = []; + } + + public static List Validate( + JsonElement value, + IOpenApiSchema? schema, + SchemaValidationDirection direction, + string path = "$", + bool strictNullableEnums = false) + { + var errors = new List(); + var state = new ValidationState { StrictNullableEnums = strictNullableEnums }; + ValidateCore(value, schema, direction, path, errors, state, 0); + if (state.BudgetExceeded) + { + errors.Add(new(path, $"Validation stopped after {MaxEvaluations} schema evaluations, so the value couldn't be fully validated")); + } + return errors; + } + + public static List Validate( + JsonNode? value, + IOpenApiSchema? schema, + SchemaValidationDirection direction, + string path = "$", + bool strictNullableEnums = false) + { + using var doc = JsonDocument.Parse(value?.ToJsonString() ?? "null"); + return Validate(doc.RootElement.Clone(), schema, direction, path, strictNullableEnums); + } + + /// + /// Converts raw (string) parameter values into a JSON value, decoding them + /// according to the parameter's serialization style and coercing them + /// to the primitive type declared by the schema when possible, so that they + /// can be validated with . + /// When is false, values are kept as strings. + /// + public static JsonNode? CoerceParameterValue( + IReadOnlyList values, + IOpenApiSchema? schema, + string name, + ParameterStyle style, + bool explode, + bool coerce = true) + { + var type = GetEffectiveType(schema); + if (type is not null && type.Value.HasFlag(JsonSchemaType.Array)) + { + var itemsSchema = GetItems(schema); + var array = new JsonArray(); + foreach (var item in SplitArrayValues(values, name, style, explode)) + { + array.Add(coerce ? CoercePrimitive(item, itemsSchema) : JsonValue.Create(item)); + } + return array; + } + + var value = values.Count > 0 ? values[0] : string.Empty; + var stripped = StripStylePrefix(value, name, style); + return coerce ? CoercePrimitive(stripped, schema) : JsonValue.Create(stripped); + } + + private static IEnumerable SplitArrayValues(IReadOnlyList values, string name, ParameterStyle style, bool explode) + { + switch (style) + { + case ParameterStyle.Form: + case ParameterStyle.SpaceDelimited: + case ParameterStyle.PipeDelimited: + // exploded arrays are sent as repeated parameters, e.g. ids=1&ids=2 + if (explode) + { + return values; + } + var delimiter = style switch + { + ParameterStyle.SpaceDelimited => ' ', + ParameterStyle.PipeDelimited => '|', + _ => ',' + }; + return values.SelectMany(v => v.Split(delimiter)); + case ParameterStyle.Label: + return values.SelectMany(v => StripStylePrefix(v, name, style).Split(explode ? '.' : ',')); + case ParameterStyle.Matrix: + if (explode) + { + // ;id=1;id=2 + var prefix = $";{name}="; + return values.SelectMany(v => v.Split(prefix, StringSplitOptions.RemoveEmptyEntries)); + } + return values.SelectMany(v => StripStylePrefix(v, name, style).Split(',')); + case ParameterStyle.Simple: + default: + return values.SelectMany(v => v.Split(',').Select(i => i.Trim())); + } + } + + private static string StripStylePrefix(string value, string name, ParameterStyle style) => style switch + { + ParameterStyle.Label when value.StartsWith('.') => value[1..], + ParameterStyle.Matrix when value.StartsWith($";{name}=", StringComparison.Ordinal) => value[(name.Length + 2)..], + ParameterStyle.Matrix when value == $";{name}" => string.Empty, + _ => value + }; + + /// + /// Gets the type declared by the schema, or when the schema doesn't declare + /// a type, the types declared by its allOf/anyOf/oneOf subschemas. + /// + public static JsonSchemaType? GetEffectiveType(IOpenApiSchema? schema) + { + JsonSchemaType? type = null; + foreach (var s in EnumerateComposition(schema, s => s.Type is null)) + { + if (s.Type is not null) + { + type = (type ?? 0) | s.Type.Value; + } + } + return type; + } + + private static IOpenApiSchema? GetItems(IOpenApiSchema? schema) => + EnumerateComposition(schema, s => s.Items is null).FirstOrDefault(s => s.Items is not null)?.Items; + + /// + /// Gets the names of the properties declared by the schema and its allOf/anyOf/oneOf subschemas. + /// + public static IEnumerable GetPropertyNames(IOpenApiSchema? schema) => + EnumerateComposition(schema, _ => true) + .SelectMany(s => s.Properties?.Keys ?? Enumerable.Empty()) + .Distinct(StringComparer.Ordinal); + + // Enumerates the schema and its allOf/anyOf/oneOf subschemas depth-first, + // visiting each (resolved) schema once so that repeated references and + // cycles don't cause exponential work. + private static IEnumerable EnumerateComposition(IOpenApiSchema? schema, Func descend) + { + if (schema is null) + { + yield break; + } + + var visited = new HashSet(ReferenceEqualityComparer.Instance); + var stack = new Stack<(IOpenApiSchema Schema, int Depth)>(); + stack.Push((schema, 0)); + while (stack.Count > 0) + { + var (current, depth) = stack.Pop(); + if (!visited.Add(ResolveTarget(current))) + { + continue; + } + + yield return current; + + if (depth < MaxDepth && descend(current)) + { + foreach (var subSchema in GetComposedSchemas(current).Reverse()) + { + stack.Push((subSchema, depth + 1)); + } + } + } + } + + private static IOpenApiSchema ResolveTarget(IOpenApiSchema schema) => + schema is OpenApiSchemaReference reference ? (IOpenApiSchema?)reference.Target ?? schema : schema; + + // Finds the declarations of the property in the schema and its allOf + // subschemas (incl. references), which all apply to the value + private static IEnumerable FindAllOfPropertySchemas(IOpenApiSchema schema, string propertyName) + { + var visited = new HashSet(ReferenceEqualityComparer.Instance); + var stack = new Stack<(IOpenApiSchema Schema, int Depth)>(); + stack.Push((schema, 0)); + while (stack.Count > 0) + { + var (current, depth) = stack.Pop(); + if (!visited.Add(ResolveTarget(current))) + { + continue; + } + + if (current.Properties?.TryGetValue(propertyName, out var propertySchema) == true) + { + yield return propertySchema; + } + + if (depth < MaxDepth) + { + foreach (var subSchema in current.AllOf ?? []) + { + stack.Push((subSchema, depth + 1)); + } + } + } + } + + private static bool IsDeclaredInSibling(IList allOf, int index, string propertyName) => + allOf.Where((_, i) => i != index) + .Any(s => GetPropertyNames(s).Contains(propertyName, StringComparer.Ordinal)); + + private static IEnumerable GetComposedSchemas(IOpenApiSchema schema) => + (schema.AllOf ?? []).Concat(schema.AnyOf ?? []).Concat(schema.OneOf ?? []); + + private static JsonValue? CoercePrimitive(string value, IOpenApiSchema? schema) + { + var type = GetEffectiveType(schema); + if (type is null) + { + return JsonValue.Create(value); + } + + if (type.Value.HasFlag(JsonSchemaType.Integer) && + long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var l)) + { + return JsonValue.Create(l); + } + if (type.Value.HasFlag(JsonSchemaType.Number) && + double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) && + double.IsFinite(d)) + { + return JsonValue.Create(d); + } + if (type.Value.HasFlag(JsonSchemaType.Boolean) && bool.TryParse(value, out var b)) + { + return JsonValue.Create(b); + } + if (type.Value.HasFlag(JsonSchemaType.Null) && value.Length == 0) + { + return null; + } + + return JsonValue.Create(value); + } + + private static void ValidateCore( + JsonElement value, + IOpenApiSchema? schema, + SchemaValidationDirection direction, + string path, + List errors, + ValidationState state, + int depth) + { + if (schema is null || state.BudgetExceeded) + { + return; + } + if (++state.Evaluations > MaxEvaluations) + { + state.BudgetExceeded = true; + return; + } + if (depth > MaxDepth) + { + errors.Add(new(path, $"Schema is nested deeper than the supported maximum of {MaxDepth} levels, so the value couldn't be validated")); + return; + } + + // errors reported for this value start here; earlier errors belong to + // other values, so they don't need to be considered for deduplication + var firstError = errors.Count; + if (!ValidateType(value, schema, path, errors)) + { + // no point validating further if the type doesn't match + return; + } + + ValidateEnum(value, schema, path, errors, state.StrictNullableEnums); + + if (value.ValueKind == JsonValueKind.Object) + { + ValidateObject(value, schema, direction, path, errors, state, depth); + } + else if (value.ValueKind == JsonValueKind.Array && schema.Items is not null) + { + var i = 0; + foreach (var item in value.EnumerateArray()) + { + ValidateCore(item, schema.Items, direction, $"{path}[{i}]", errors, state, depth + 1); + i++; + } + } + + if (schema.AllOf is { Count: > 0 }) + { + var seen = new HashSet(errors.Skip(firstError)); + for (var i = 0; i < schema.AllOf.Count; i++) + { + foreach (var subError in ValidateComposed(value, schema.AllOf[i], direction, path, state, depth + 1)) + { + var error = subError; + if (error.UndefinedProperty is not null && + error.Path == GetPropertyPath(path, error.UndefinedProperty) && + IsDeclaredInSibling(schema.AllOf, i, error.UndefinedProperty)) + { + // additionalProperties: false applies to each subschema on its own, + // which is spec-compliant but often unexpected + error = error with + { + Message = $"{error.Message}. The property is declared in a sibling allOf schema; additionalProperties: false applies per subschema", + UndefinedProperty = null + }; + } + + // subschemas can report the same error, e.g. when they reference the same schema + if (seen.Add(error)) + { + errors.Add(error); + } + } + } + } + + if (schema.AnyOf is { Count: > 0 } && + !schema.AnyOf.Any(s => IsValid(value, s, direction, path, state, depth))) + { + errors.Add(new(path, "Value doesn't match any of the schemas in 'anyOf'")); + } + + if (schema.OneOf is { Count: > 0 } && + schema.Discriminator?.PropertyName is { Length: > 0 } && + value.ValueKind == JsonValueKind.Object) + { + ValidateDiscriminatedOneOf(value, schema, schema.Discriminator, direction, path, errors, firstError, state, depth); + } + else if (schema.OneOf is { Count: > 0 }) + { + var matches = schema.OneOf.Count(s => IsValid(value, s, direction, path, state, depth)); + if (matches == 0) + { + errors.Add(new(path, "Value doesn't match any of the schemas in 'oneOf'")); + } + else if (matches > 1) + { + errors.Add(new(path, $"Value matches {matches} schemas in 'oneOf' but must match exactly one")); + } + } + } + + // Validates the value only against the oneOf branch selected by the + // discriminator, so that overlapping branches don't cause false positives + private static void ValidateDiscriminatedOneOf( + JsonElement value, + IOpenApiSchema schema, + OpenApiDiscriminator discriminator, + SchemaValidationDirection direction, + string path, + List errors, + int firstError, + ValidationState state, + int depth) + { + var propertyName = discriminator.PropertyName!; + var oneOf = schema.OneOf!; + var errorPath = path; + IOpenApiSchema? candidate; + string source; + if (!value.TryGetProperty(propertyName, out var discriminatorValue)) + { + if (discriminator.DefaultMapping is null) + { + errors.Add(new(path, $"Missing discriminator property '{propertyName}' required to select a schema in 'oneOf'")); + return; + } + candidate = discriminator.DefaultMapping; + source = "Discriminator default mapping"; + } + else if (discriminatorValue.ValueKind != JsonValueKind.String) + { + errors.Add(new(GetPropertyPath(path, propertyName), $"Discriminator property '{propertyName}' must be a string but got {DescribeValueKind(discriminatorValue)}")); + return; + } + else + { + var discriminatorName = discriminatorValue.GetString()!; + errorPath = GetPropertyPath(path, propertyName); + source = $"Discriminator value '{discriminatorName}'"; + // explicit mappings take precedence over implicit ones, which use + // the name of the referenced component schema + candidate = discriminator.Mapping is not null && + discriminator.Mapping.TryGetValue(discriminatorName, out var mapped) + ? mapped + : oneOf.FirstOrDefault(s => GetSchemaName(s) == discriminatorName); + if (candidate is null && discriminator.DefaultMapping is not null) + { + candidate = discriminator.DefaultMapping; + source = "Discriminator default mapping"; + } + if (candidate is null) + { + var allowed = GetDiscriminatorValues(oneOf, discriminator); + var allowedMessage = allowed.Count > 0 + ? $". Allowed values: {string.Join(", ", allowed.Select(v => $"'{v}'"))}" + : string.Empty; + errors.Add(new(errorPath, $"Discriminator value '{discriminatorName}' doesn't map to any schema in 'oneOf'{allowedMessage}")); + return; + } + } + + // mappings must not select a schema outside the declared alternatives, + // otherwise the value could bypass oneOf validation + var selected = FindOneOfBranch(oneOf, candidate); + if (selected is null) + { + var schemaName = GetSchemaName(candidate) is { } name ? $"schema '{name}'" : "a schema"; + errors.Add(new(errorPath, $"{source} maps to {schemaName}, which isn't one of the schemas in 'oneOf'")); + return; + } + + var seen = new HashSet(errors.Skip(firstError)); + foreach (var error in ValidateComposed(value, selected, direction, path, state, depth + 1)) + { + if (seen.Add(error)) + { + errors.Add(error); + } + } + } + + private static IOpenApiSchema? FindOneOfBranch(IList oneOf, IOpenApiSchema candidate) + { + var target = ResolveTarget(candidate); + var name = GetSchemaName(candidate); + return oneOf.FirstOrDefault(s => + ReferenceEquals(ResolveTarget(s), target) || + (name is not null && GetSchemaName(s) == name)); + } + + private static List GetDiscriminatorValues(IList oneOf, OpenApiDiscriminator discriminator) => + [.. (discriminator.Mapping ?? Enumerable.Empty>()) + .Where(m => FindOneOfBranch(oneOf, m.Value) is not null) + .Select(m => m.Key) + .Concat(oneOf.Select(GetSchemaName).OfType()) + .Distinct(StringComparer.Ordinal)]; + + private static string? GetSchemaName(IOpenApiSchema schema) => + schema is OpenApiSchemaReference reference ? reference.Reference?.Id : null; + + private static bool IsValid(JsonElement value, IOpenApiSchema schema, SchemaValidationDirection direction, string path, ValidationState state, int depth) => + ValidateComposed(value, schema, direction, path, state, depth + 1).Count == 0; + + // Composition keywords can reference the same subschema many times for the + // same value. Within a single validation, a path identifies a value, so + // results are memoized per (resolved schema, path, depth) to avoid exponential + // work. Depth is part of the key, because results near the depth limit differ. + private static List ValidateComposed(JsonElement value, IOpenApiSchema schema, SchemaValidationDirection direction, string path, ValidationState state, int depth) + { + var target = ResolveTarget(schema); + if (state.Memo.TryGetValue((target, path, depth), out var cached)) + { + return cached; + } + + var subErrors = new List(); + ValidateCore(value, schema, direction, path, subErrors, state, depth); + state.Memo[(target, path, depth)] = subErrors; + return subErrors; + } + + private static bool ValidateType(JsonElement value, IOpenApiSchema schema, string path, List errors) + { + if (schema.Type is not { } type || type == 0) + { + return true; + } + + var matches = value.ValueKind switch + { + JsonValueKind.Null => type.HasFlag(JsonSchemaType.Null), + JsonValueKind.True or JsonValueKind.False => type.HasFlag(JsonSchemaType.Boolean), + JsonValueKind.String => type.HasFlag(JsonSchemaType.String), + JsonValueKind.Object => type.HasFlag(JsonSchemaType.Object), + JsonValueKind.Array => type.HasFlag(JsonSchemaType.Array), + JsonValueKind.Number => type.HasFlag(JsonSchemaType.Number) || + (type.HasFlag(JsonSchemaType.Integer) && IsInteger(value)), + JsonValueKind.Undefined => true, + _ => true + }; + + if (!matches) + { + errors.Add(new(path, $"Expected {DescribeType(type)} but got {DescribeValueKind(value)}")); + } + + return matches; + } + + private static bool IsInteger(JsonElement value) => + value.TryGetInt64(out _) || + (value.TryGetDecimal(out var d) && decimal.Truncate(d) == d); + + private static void ValidateEnum(JsonElement value, IOpenApiSchema schema, string path, List errors, bool strictNullableEnums) + { + if (schema.Enum is not { Count: > 0 }) + { + return; + } + + var node = JsonNode.Parse(value.GetRawText()); + if (schema.Enum.Any(e => JsonNode.DeepEquals(e, node))) + { + return; + } + + // OAS 3.0 nullable enums: null is allowed if the schema allows null. + // Microsoft.OpenApi maps 'nullable' to the null type, so this can't be + // distinguished from OAS 3.1, where null must be listed in the enum + if (!strictNullableEnums && node is null && schema.Type is { } type && type.HasFlag(JsonSchemaType.Null)) + { + return; + } + + var allowed = string.Join(", ", schema.Enum.Select(e => e?.ToJsonString() ?? "null")); + errors.Add(new(path, $"Value {value.GetRawText()} isn't one of the allowed values: {allowed}")); + } + + private static void ValidateObject( + JsonElement value, + IOpenApiSchema schema, + SchemaValidationDirection direction, + string path, + List errors, + ValidationState state, + int depth) + { + if (schema.Required is { Count: > 0 }) + { + foreach (var requiredProperty in schema.Required) + { + if (value.TryGetProperty(requiredProperty, out _)) + { + continue; + } + + // readOnly properties aren't sent in requests, writeOnly + // properties aren't returned in responses + if (FindAllOfPropertySchemas(schema, requiredProperty).Any(propertySchema => + (direction == SchemaValidationDirection.Request && propertySchema.ReadOnly) || + (direction == SchemaValidationDirection.Response && propertySchema.WriteOnly))) + { + continue; + } + + errors.Add(new(path, $"Missing required property '{requiredProperty}'")); + } + } + + foreach (var property in value.EnumerateObject()) + { + var propertyPath = GetPropertyPath(path, property.Name); + if (schema.Properties is not null && + schema.Properties.TryGetValue(property.Name, out var propertySchema)) + { + ValidateCore(property.Value, propertySchema, direction, propertyPath, errors, state, depth + 1); + } + else if (schema.AdditionalProperties is not null) + { + ValidateCore(property.Value, schema.AdditionalProperties, direction, propertyPath, errors, state, depth + 1); + } + else if (!schema.AdditionalPropertiesAllowed) + { + errors.Add(new(propertyPath, $"Property '{property.Name}' isn't defined in the schema", property.Name)); + } + } + } + + // Use bracket notation for property names that would make the path + // ambiguous (e.g. 'a.b' vs. 'a' -> 'b'), because paths are also used as memo keys + private static string GetPropertyPath(string path, string propertyName) => + propertyName.Length == 0 || propertyName.IndexOfAny(['.', '[', ']', '"']) >= 0 + ? $"{path}[{JsonSerializer.Serialize(propertyName)}]" + : $"{path}.{propertyName}"; + + private static string DescribeType(JsonSchemaType type) + { + var names = Enum.GetValues() + .Where(t => type.HasFlag(t)) + .OrderBy(t => t == JsonSchemaType.Null) + .Select(t => t.ToString().ToLowerInvariant()); + return string.Join(" or ", names); + } + + private static string DescribeValueKind(JsonElement value) => value.ValueKind switch + { + JsonValueKind.Null => "null", + JsonValueKind.True or JsonValueKind.False => "boolean", + JsonValueKind.String => "string", + JsonValueKind.Object => "object", + JsonValueKind.Array => "array", + JsonValueKind.Number => IsInteger(value) ? "integer" : "number", + _ => value.ValueKind.ToString().ToLowerInvariant() + }; +} diff --git a/schemas/v4.1.0/openapivalidationplugin.schema.json b/schemas/v4.1.0/openapivalidationplugin.schema.json new file mode 100644 index 00000000..9eca2386 --- /dev/null +++ b/schemas/v4.1.0/openapivalidationplugin.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy OpenApiValidationPlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "specFiles": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Relative or absolute paths to OpenAPI spec files (JSON or YAML) to validate requests and responses against." + }, + "specsFolder": { + "type": "string", + "description": "Relative or absolute path to a folder with OpenAPI spec files (JSON or YAML). The folder is scanned recursively." + }, + "reportUnmatchedRequests": { + "type": "boolean", + "default": false, + "description": "Set to true to list requests that don't match any of the OpenAPI specs in the report." + } + }, + "anyOf": [ + { + "required": [ + "specFiles" + ] + }, + { + "required": [ + "specsFolder" + ] + } + ], + "additionalProperties": false +}