diff --git a/.github/workflows/lint-openapi.yml b/.github/workflows/lint-openapi.yml index 3324bcd..4e25893 100644 --- a/.github/workflows/lint-openapi.yml +++ b/.github/workflows/lint-openapi.yml @@ -18,4 +18,4 @@ jobs: - name: Lint OpenAPI specs run: | - vacuum lint --globbed-files="*/**/openapi.json" + vacuum lint --globbed-files="tests/{*/,*/*/}openapi*.json" diff --git a/OpenAPI.WebApiGenerator.sln b/OpenAPI.WebApiGenerator.sln index 97dda50..a8837be 100644 --- a/OpenAPI.WebApiGenerator.sln +++ b/OpenAPI.WebApiGenerator.sln @@ -24,6 +24,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example.OpenApi31", "tests\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example.OpenApi31.IntegrationTests", "tests\Example.OpenApi31.IntegrationTests\Example.OpenApi31.IntegrationTests.csproj", "{FE9CE314-77B1-4064-B4E3-F2FCE3EDB63C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example.OpenApi32", "tests\Example.OpenApi32\Example.OpenApi32.csproj", "{91D58EA1-8ECF-4C18-AB2F-84CDABD22092}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example.OpenApi32.IntegrationTests", "tests\Example.OpenApi32.IntegrationTests\Example.OpenApi32.IntegrationTests.csproj", "{CFC6595B-DAA2-4866-AE50-6A9AD7863160}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -130,6 +134,30 @@ Global {FE9CE314-77B1-4064-B4E3-F2FCE3EDB63C}.Release|x64.Build.0 = Release|Any CPU {FE9CE314-77B1-4064-B4E3-F2FCE3EDB63C}.Release|x86.ActiveCfg = Release|Any CPU {FE9CE314-77B1-4064-B4E3-F2FCE3EDB63C}.Release|x86.Build.0 = Release|Any CPU + {91D58EA1-8ECF-4C18-AB2F-84CDABD22092}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {91D58EA1-8ECF-4C18-AB2F-84CDABD22092}.Debug|Any CPU.Build.0 = Debug|Any CPU + {91D58EA1-8ECF-4C18-AB2F-84CDABD22092}.Debug|x64.ActiveCfg = Debug|Any CPU + {91D58EA1-8ECF-4C18-AB2F-84CDABD22092}.Debug|x64.Build.0 = Debug|Any CPU + {91D58EA1-8ECF-4C18-AB2F-84CDABD22092}.Debug|x86.ActiveCfg = Debug|Any CPU + {91D58EA1-8ECF-4C18-AB2F-84CDABD22092}.Debug|x86.Build.0 = Debug|Any CPU + {91D58EA1-8ECF-4C18-AB2F-84CDABD22092}.Release|Any CPU.ActiveCfg = Release|Any CPU + {91D58EA1-8ECF-4C18-AB2F-84CDABD22092}.Release|Any CPU.Build.0 = Release|Any CPU + {91D58EA1-8ECF-4C18-AB2F-84CDABD22092}.Release|x64.ActiveCfg = Release|Any CPU + {91D58EA1-8ECF-4C18-AB2F-84CDABD22092}.Release|x64.Build.0 = Release|Any CPU + {91D58EA1-8ECF-4C18-AB2F-84CDABD22092}.Release|x86.ActiveCfg = Release|Any CPU + {91D58EA1-8ECF-4C18-AB2F-84CDABD22092}.Release|x86.Build.0 = Release|Any CPU + {CFC6595B-DAA2-4866-AE50-6A9AD7863160}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CFC6595B-DAA2-4866-AE50-6A9AD7863160}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CFC6595B-DAA2-4866-AE50-6A9AD7863160}.Debug|x64.ActiveCfg = Debug|Any CPU + {CFC6595B-DAA2-4866-AE50-6A9AD7863160}.Debug|x64.Build.0 = Debug|Any CPU + {CFC6595B-DAA2-4866-AE50-6A9AD7863160}.Debug|x86.ActiveCfg = Debug|Any CPU + {CFC6595B-DAA2-4866-AE50-6A9AD7863160}.Debug|x86.Build.0 = Debug|Any CPU + {CFC6595B-DAA2-4866-AE50-6A9AD7863160}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CFC6595B-DAA2-4866-AE50-6A9AD7863160}.Release|Any CPU.Build.0 = Release|Any CPU + {CFC6595B-DAA2-4866-AE50-6A9AD7863160}.Release|x64.ActiveCfg = Release|Any CPU + {CFC6595B-DAA2-4866-AE50-6A9AD7863160}.Release|x64.Build.0 = Release|Any CPU + {CFC6595B-DAA2-4866-AE50-6A9AD7863160}.Release|x86.ActiveCfg = Release|Any CPU + {CFC6595B-DAA2-4866-AE50-6A9AD7863160}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/README.md b/README.md index 74fc4ee..1ae1734 100644 --- a/README.md +++ b/README.md @@ -5,15 +5,16 @@ Generates scaffolding for Web APIs from OpenAPI specifications. The generated functionality will route, serialize/deserialize and validate payloads according to the specification. Supported OpenAPI version: -- [2.0](https://spec.openapis.org/oas/v2.0.html) -- [3.0.0](https://spec.openapis.org/oas/v3.0.0.html) -- [3.0.1](https://spec.openapis.org/oas/v3.0.1.html) -- [3.0.2](https://spec.openapis.org/oas/v3.0.2.html) -- [3.0.3](https://spec.openapis.org/oas/v3.0.3.html) -- [3.0.4](https://spec.openapis.org/oas/v3.0.4.html) -- [3.1.0](https://spec.openapis.org/oas/v3.1.0.html) -- [3.1.1](https://spec.openapis.org/oas/v3.1.1.html) +- [3.2.0](https://spec.openapis.org/oas/v3.2.0.html) - [3.1.2](https://spec.openapis.org/oas/v3.1.2.html) +- [3.1.1](https://spec.openapis.org/oas/v3.1.1.html) +- [3.1.0](https://spec.openapis.org/oas/v3.1.0.html) +- [3.0.4](https://spec.openapis.org/oas/v3.0.4.html) +- [3.0.3](https://spec.openapis.org/oas/v3.0.3.html) +- [3.0.2](https://spec.openapis.org/oas/v3.0.2.html) +- [3.0.1](https://spec.openapis.org/oas/v3.0.1.html) +- [3.0.0](https://spec.openapis.org/oas/v3.0.0.html) +- [2.0](https://spec.openapis.org/oas/v2.0.html) API frameworks supported: - [Minimal API](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis) @@ -67,10 +68,11 @@ Examples: - [OpenAPI 2.0](tests/Example.OpenApi20) - [OpenAPI 3.0](tests/Example.OpenApi30) - [OpenAPI 3.1](tests/Example.OpenApi31) +- [OpenAPI 3.2](tests/Example.OpenApi32) All specifications mostly generate similar abstractions. What might differ is the location of generated resources, which follows the respective structure of the OpenAPI specification, and the JSON types, which are based on the respective schema version. -**Note**: The Examples reference the generator through a project reference. Use a package reference instead as described above. +**Note**: The examples reference the generator through a project reference. Use a package reference instead as described above. ## Implementing an [API Operation](https://swagger.io/specification/#operation-object) The generator generates stubbed partial classes for any operation handlers (`Foo.Bar.Operation.Handler.cs`) if there are none existing in the project and logs it with a compiler warning (AF1001). The classes should be copied into source control and the operation methods implemented. The operation methods have a familiar request/response design: diff --git a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs index a7058df..0f51809 100644 --- a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs @@ -141,8 +141,8 @@ private static void GenerateCode(SourceProductionContext context, ( { var contentGenerators = body.GetContent().Select(pair => { - var requestBodyContent = pair.Value; - var schemaReference = openApiOperationVisitor.GetSchemaReference(requestBodyContent); + var mediaType = pair.Value; + var schemaReference = openApiOperationVisitor.GetSchemaReference(mediaType); var typeDeclaration = schemaGenerator.Generate(schemaReference); return new RequestBodyContentGenerator( pair.Key, diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/SchemaGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/SchemaGenerator.cs index ce2556e..04c85a2 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/SchemaGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/SchemaGenerator.cs @@ -38,6 +38,8 @@ internal static SchemaGenerator For( Corvus.Json.CodeGeneration.OpenApi30.VocabularyAnalyser.DefaultVocabulary, OpenApiSpecVersion.OpenApi3_1 => Corvus.Json.CodeGeneration.Draft202012.VocabularyAnalyser.DefaultVocabulary, + OpenApiSpecVersion.OpenApi3_2 => + Corvus.Json.CodeGeneration.Draft202012.VocabularyAnalyser.DefaultVocabulary, _ => throw new InvalidOperationException($"OpenAPI specification {openApiSpecVersion} is not supported") }; var globalOptions = diff --git a/src/OpenAPI.WebApiGenerator/OpenAPI.WebApiGenerator.csproj b/src/OpenAPI.WebApiGenerator/OpenAPI.WebApiGenerator.csproj index edda743..0a48217 100644 --- a/src/OpenAPI.WebApiGenerator/OpenAPI.WebApiGenerator.csproj +++ b/src/OpenAPI.WebApiGenerator/OpenAPI.WebApiGenerator.csproj @@ -58,7 +58,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/OpenApiRequestBodyExtensions.cs b/src/OpenAPI.WebApiGenerator/OpenApi/OpenApiRequestBodyExtensions.cs index 2b74ba8..cb6b43b 100644 --- a/src/OpenAPI.WebApiGenerator/OpenApi/OpenApiRequestBodyExtensions.cs +++ b/src/OpenAPI.WebApiGenerator/OpenApi/OpenApiRequestBodyExtensions.cs @@ -6,6 +6,6 @@ namespace OpenAPI.WebApiGenerator.OpenApi; internal static class OpenApiRequestBodyExtensions { - internal static IDictionary GetContent(this IOpenApiRequestBody requestBody) => + internal static IDictionary GetContent(this IOpenApiRequestBody requestBody) => requestBody.Content ?? throw new NullReferenceException("Request body content is required"); } \ No newline at end of file diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/OpenApiVersionExtensions.cs b/src/OpenAPI.WebApiGenerator/OpenApi/OpenApiVersionExtensions.cs index 3e7aba3..3c6feff 100644 --- a/src/OpenAPI.WebApiGenerator/OpenApi/OpenApiVersionExtensions.cs +++ b/src/OpenAPI.WebApiGenerator/OpenApi/OpenApiVersionExtensions.cs @@ -12,11 +12,13 @@ internal static class OpenApiVersionExtensions OpenApiSpecVersion.OpenApi2_0 => "2.0", OpenApiSpecVersion.OpenApi3_0 => "3.0", OpenApiSpecVersion.OpenApi3_1 => "3.1", + OpenApiSpecVersion.OpenApi3_2 => "3.2", _ => throw new NotSupportedException($"OpenAPI version {Enum.GetName(typeof(OpenApiSpecVersion), version)} not supported") }; internal static Action GetSerializer(this IOpenApiSerializable parameter, OpenApiSpecVersion version) => version switch { + OpenApiSpecVersion.OpenApi3_2 => parameter.SerializeAsV32, OpenApiSpecVersion.OpenApi3_1 => parameter.SerializeAsV31, OpenApiSpecVersion.OpenApi3_0 => parameter.SerializeAsV3, OpenApiSpecVersion.OpenApi2_0 => parameter.SerializeAsV2, diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/IOpenApiOperationVisitor.cs b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/IOpenApiOperationVisitor.cs index 8b88987..006ac4a 100644 --- a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/IOpenApiOperationVisitor.cs +++ b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/IOpenApiOperationVisitor.cs @@ -6,6 +6,6 @@ namespace OpenAPI.WebApiGenerator.OpenApi.Visitor; internal interface IOpenApiOperationVisitor : IVisitor { public JsonReference GetSchemaReference(IOpenApiParameter parameter); - public JsonReference GetSchemaReference(OpenApiMediaType requestBodyContent); + public JsonReference GetSchemaReference(IOpenApiMediaType requestBodyContent); public IOpenApiResponseVisitor Visit(IOpenApiResponse response); } \ No newline at end of file diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/IOpenApiResponseVisitor.cs b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/IOpenApiResponseVisitor.cs index b37f090..242ab4d 100644 --- a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/IOpenApiResponseVisitor.cs +++ b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/IOpenApiResponseVisitor.cs @@ -5,7 +5,7 @@ namespace OpenAPI.WebApiGenerator.OpenApi.Visitor; internal interface IOpenApiResponseVisitor { - public JsonReference GetSchemaReference(OpenApiMediaType mediaType); - public bool HasContent(OpenApiMediaType mediaType); + public JsonReference GetSchemaReference(IOpenApiMediaType mediaType); + public bool HasContent(IOpenApiMediaType mediaType); public JsonReference GetSchemaReference(IOpenApiHeader header); } \ No newline at end of file diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/OpenApiVisitor.cs b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/OpenApiVisitor.cs index d9e2c6e..e78b36d 100644 --- a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/OpenApiVisitor.cs +++ b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/OpenApiVisitor.cs @@ -17,6 +17,7 @@ public static IOpenApiVisitor V(OpenApiSpecVersion version, OpenApiReference OpenApiV2Visitor.Visit(openApiReference), OpenApiSpecVersion.OpenApi3_0 => OpenApiV3Visitor.Visit(openApiReference), OpenApiSpecVersion.OpenApi3_1 => OpenApiV3Visitor.Visit(openApiReference), + OpenApiSpecVersion.OpenApi3_2 => OpenApiV3Visitor.Visit(openApiReference), _ => throw new InvalidOperationException($"OpenAPI version {version} not supported") }; } diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V2/OpenApiV2Visitor.OperationVisitor.cs b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V2/OpenApiV2Visitor.OperationVisitor.cs index 8eb8a40..4d64527 100644 --- a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V2/OpenApiV2Visitor.OperationVisitor.cs +++ b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V2/OpenApiV2Visitor.OperationVisitor.cs @@ -15,7 +15,8 @@ private sealed class OperationVisitor : private Dictionary _parameterSchemaReferences = new(); private JsonReference? _bodySchemaReference; private readonly Dictionary _responseVisitors = new(); - + private JsonReference? _formDataSchemaReference; + private OperationVisitor(OpenApiReference openApiReference) : base(openApiReference) { VisitParameters(); @@ -36,6 +37,7 @@ private void VisitParameters() new JsonReference(Reference.Uri, parametersPointer.ToString().AsSpan()))); _parameterSchemaReferences = parametersVisitor.Schemas; _bodySchemaReference = parametersVisitor.BodySchema; + _formDataSchemaReference = parametersVisitor.FormData; } private void VisitResponses() @@ -58,8 +60,8 @@ internal static OperationVisitor Visit( public JsonReference GetSchemaReference(IOpenApiParameter parameter) => _parameterSchemaReferences[parameter]; - public JsonReference GetSchemaReference(OpenApiMediaType requestBodyContent) => - _bodySchemaReference ?? throw new InvalidOperationException("Operation doesn't define a body"); + public JsonReference GetSchemaReference(IOpenApiMediaType mediaType) => + _bodySchemaReference ?? _formDataSchemaReference ?? throw new InvalidOperationException($"Operation {Pointer} doesn't define a body or formData"); public IOpenApiResponseVisitor Visit(IOpenApiResponse response) => _responseVisitors[response]; diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V2/OpenApiV2Visitor.ParametersVisitor.cs b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V2/OpenApiV2Visitor.ParametersVisitor.cs index b81b5e6..8b0b202 100644 --- a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V2/OpenApiV2Visitor.ParametersVisitor.cs +++ b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V2/OpenApiV2Visitor.ParametersVisitor.cs @@ -17,6 +17,7 @@ private ParametersVisitor(OpenApiReference> openApiRefe internal Dictionary Schemas { get; } = new(); internal JsonReference? BodySchema { get; private set; } + internal JsonReference? FormData { get; private set; } internal static ParametersVisitor Visit(OpenApiReference> openApiReference) => new(openApiReference); @@ -45,11 +46,16 @@ private void VisitParameters() parameters.Add((parameterName, parameterLocation), new JsonReference(Reference.Uri, schemaPointer.ToString().AsSpan())); - if (parameterLocation == "body") + switch (parameterLocation) { - BodySchema = parameters[(parameterName, parameterLocation)]; + case "body": + BodySchema = parameters[(parameterName, parameterLocation)]; + break; + case "formData": + FormData = parameters[(parameterName, parameterLocation)]; + break; } - + parameterIndex++; } diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V2/OpenApiV2Visitor.ResponseVisitor.cs b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V2/OpenApiV2Visitor.ResponseVisitor.cs index ab6c8fc..ccf0cbd 100644 --- a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V2/OpenApiV2Visitor.ResponseVisitor.cs +++ b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V2/OpenApiV2Visitor.ResponseVisitor.cs @@ -44,10 +44,10 @@ private void VisitHeaders() } } - public JsonReference GetSchemaReference(OpenApiMediaType mediaType) => + public JsonReference GetSchemaReference(IOpenApiMediaType mediaType) => _contentSchemaReference ?? throw new InvalidOperationException("Response has no content defined"); - public bool HasContent(OpenApiMediaType mediaType) => + public bool HasContent(IOpenApiMediaType mediaType) => _contentSchemaReference != null; public JsonReference GetSchemaReference(IOpenApiHeader header) => diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.OperationVisitor.cs b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.OperationVisitor.cs index a186e11..a0686ce 100644 --- a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.OperationVisitor.cs +++ b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.OperationVisitor.cs @@ -14,7 +14,7 @@ private sealed class OperationVisitor : { private Dictionary _parameterSchemaReferences = new(); private readonly Dictionary _responseVisitors = new(); - private readonly Dictionary _requestContentSchemaReferences = new(); + private readonly Dictionary _requestContentSchemaReferences = new(); private OperationVisitor(OpenApiReference openApiReference) : base(openApiReference) { @@ -78,8 +78,8 @@ internal static OperationVisitor Visit( public JsonReference GetSchemaReference(IOpenApiParameter parameter) => _parameterSchemaReferences[parameter]; - public JsonReference GetSchemaReference(OpenApiMediaType requestBodyContent) => - _requestContentSchemaReferences[requestBodyContent]; + public JsonReference GetSchemaReference(IOpenApiMediaType mediaType) => + _requestContentSchemaReferences[mediaType]; public IOpenApiResponseVisitor Visit(IOpenApiResponse response) => _responseVisitors[response]; diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.ResponseVisitor.cs b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.ResponseVisitor.cs index 49bd034..92f6d76 100644 --- a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.ResponseVisitor.cs +++ b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.ResponseVisitor.cs @@ -17,7 +17,7 @@ private ResponseVisitor(OpenApiReference openApiReference) : b } private readonly Dictionary _headerReferences = new(); - private readonly Dictionary _contentReferences = new(); + private readonly Dictionary _contentReferences = new(); internal static ResponseVisitor Visit(OpenApiReference openApiReference) => new(openApiReference); @@ -56,10 +56,10 @@ private void VisitHeaders() } } - public JsonReference GetSchemaReference(OpenApiMediaType mediaType) => + public JsonReference GetSchemaReference(IOpenApiMediaType mediaType) => _contentReferences[mediaType]; - public bool HasContent(OpenApiMediaType mediaType) => + public bool HasContent(IOpenApiMediaType mediaType) => _contentReferences.ContainsKey(mediaType); public JsonReference GetSchemaReference(IOpenApiHeader header) => diff --git a/tests/Example.OpenApi32.IntegrationTests/DeleteFooTests.cs b/tests/Example.OpenApi32.IntegrationTests/DeleteFooTests.cs new file mode 100644 index 0000000..93ab0e8 --- /dev/null +++ b/tests/Example.OpenApi32.IntegrationTests/DeleteFooTests.cs @@ -0,0 +1,24 @@ +using System.Net; +using AwesomeAssertions; + +namespace Example.OpenApi32.IntegrationTests; + +public class DeleteFooTests(FooApplicationFactory app) : FooTestSpecification, IClassFixture +{ + [Fact] + public async Task When_Deleting_Foo_It_Should_Return_Ok() + { + using var client = app.CreateClient(); + var result = await client.SendAsync(new HttpRequestMessage() + { + RequestUri = new Uri(client.BaseAddress!, "/foo/1"), + Method = new HttpMethod("DELETE") + }, CancellationToken); + result.StatusCode.Should().Be(HttpStatusCode.OK); + var responseContent = await result.Content.ReadAsByteArrayAsync(CancellationToken); + responseContent.Should().BeEmpty(); + result.Content.Headers.ContentType.Should().BeNull(); + + result.Headers.Should().BeEmpty(); + } +} diff --git a/tests/Example.OpenApi32.IntegrationTests/Example.OpenApi32.IntegrationTests.csproj b/tests/Example.OpenApi32.IntegrationTests/Example.OpenApi32.IntegrationTests.csproj new file mode 100644 index 0000000..2fe1e62 --- /dev/null +++ b/tests/Example.OpenApi32.IntegrationTests/Example.OpenApi32.IntegrationTests.csproj @@ -0,0 +1,32 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + diff --git a/tests/Example.OpenApi32.IntegrationTests/FooApplicationFactory.cs b/tests/Example.OpenApi32.IntegrationTests/FooApplicationFactory.cs new file mode 100644 index 0000000..383b35b --- /dev/null +++ b/tests/Example.OpenApi32.IntegrationTests/FooApplicationFactory.cs @@ -0,0 +1,7 @@ +using JetBrains.Annotations; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Example.OpenApi32.IntegrationTests; + +[UsedImplicitly] +public class FooApplicationFactory : WebApplicationFactory; diff --git a/tests/Example.OpenApi32.IntegrationTests/FooTestSpecification.cs b/tests/Example.OpenApi32.IntegrationTests/FooTestSpecification.cs new file mode 100644 index 0000000..845dacd --- /dev/null +++ b/tests/Example.OpenApi32.IntegrationTests/FooTestSpecification.cs @@ -0,0 +1,13 @@ +using System.Text; + +namespace Example.OpenApi32.IntegrationTests; + +public abstract class FooTestSpecification +{ + protected CancellationToken CancellationToken { get; } = TestContext.Current.CancellationToken; + + protected HttpContent CreateJsonContent(string json) => new StringContent( + json, + encoding: Encoding.UTF8, + mediaType: "application/json"); +} diff --git a/tests/Example.OpenApi32.IntegrationTests/Http/HttpContentExtensions.cs b/tests/Example.OpenApi32.IntegrationTests/Http/HttpContentExtensions.cs new file mode 100644 index 0000000..70b813a --- /dev/null +++ b/tests/Example.OpenApi32.IntegrationTests/Http/HttpContentExtensions.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Nodes; + +namespace Example.OpenApi32.IntegrationTests.Http; + +internal static class HttpContentExtensions +{ + internal static async Task ReadAsJsonNodeAsync(this HttpContent content, + CancellationToken cancellationToken) => + await JsonNode.ParseAsync( + await content.ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false), + cancellationToken: cancellationToken) + .ConfigureAwait(false); +} diff --git a/tests/Example.OpenApi32.IntegrationTests/Json/JsonNodeExtensions.cs b/tests/Example.OpenApi32.IntegrationTests/Json/JsonNodeExtensions.cs new file mode 100644 index 0000000..79b7a26 --- /dev/null +++ b/tests/Example.OpenApi32.IntegrationTests/Json/JsonNodeExtensions.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Nodes; +using AwesomeAssertions; +using Json.Pointer; + +namespace Example.OpenApi32.IntegrationTests.Json; + +internal static class JsonNodeExtensions +{ + internal static JsonNode Evaluate(this JsonNode? node, string path) + { + JsonPointer.Parse(path).TryEvaluate(node, out var value).Should() + .BeTrue($"because the json node should contain the property {path}"); + value.Should().NotBeNull($"because the property {path} should not be null"); + return value!; + } + + internal static T GetValue(this JsonNode? node, string path) + { + var value = node.Evaluate(path); + return value.GetValue(); + } + +} diff --git a/tests/Example.OpenApi32.IntegrationTests/UpdateFooTests.cs b/tests/Example.OpenApi32.IntegrationTests/UpdateFooTests.cs new file mode 100644 index 0000000..576443f --- /dev/null +++ b/tests/Example.OpenApi32.IntegrationTests/UpdateFooTests.cs @@ -0,0 +1,67 @@ +using System.Net; +using System.Net.Http.Headers; +using AwesomeAssertions; +using Example.OpenApi32.IntegrationTests.Http; +using Example.OpenApi32.IntegrationTests.Json; + +namespace Example.OpenApi32.IntegrationTests; + +public class UpdateFooTests(FooApplicationFactory app) : FooTestSpecification, IClassFixture +{ + [Fact] + public async Task When_Updating_Foo_It_Should_Return_Updated_Foo() + { + using var client = app.CreateClient(); + var result = await client.SendAsync(new HttpRequestMessage() + { + RequestUri = new Uri(client.BaseAddress!, "/foo/1"), + Method = new HttpMethod("PUT"), + Content = CreateJsonContent( + """ + { + "Name": "test" + } + """), + Headers = + { + { "Bar", "test" } + } + }, CancellationToken); + result.StatusCode.Should().Be(HttpStatusCode.OK); + var responseContent = await result.Content.ReadAsJsonNodeAsync(CancellationToken); + responseContent.Should().NotBeNull(); + responseContent.GetValue("#/Name").Should().Be("test"); + result.Headers.Should().HaveCount(1); + result.Headers.Should().ContainKey("Status") + .WhoseValue.Should().HaveCount(1) + .And.Contain("2"); + result.Content.Headers.ContentType.Should().Be(MediaTypeHeaderValue.Parse("application/json")); + } + + [Fact] + public async Task Given_invalid_request_When_Updating_Foo_It_Should_Return_400() + { + using var client = app.CreateClient(); + var result = await client.SendAsync(new HttpRequestMessage() + { + RequestUri = new Uri(client.BaseAddress!, "/foo/test"), + Method = new HttpMethod("PUT"), + Content = CreateJsonContent( + """ + { + "Name": "test" + } + """), + Headers = + { + { "Bar", "test" } + } + }, CancellationToken); + result.StatusCode.Should().Be(HttpStatusCode.BadRequest); + var responseContent = await result.Content.ReadAsJsonNodeAsync(CancellationToken); + responseContent.Should().NotBeNull(); + responseContent.AsArray().Should().HaveCount(1); + responseContent.GetValue("#/0/error").Should().NotBeNullOrEmpty(); + responseContent.GetValue("#/0/name").Should().Be("https://localhost/api.json#/components/parameters/FooId/schema/type"); + } +} diff --git a/tests/Example.OpenApi32/Example.OpenApi32.csproj b/tests/Example.OpenApi32/Example.OpenApi32.csproj new file mode 100644 index 0000000..34c3039 --- /dev/null +++ b/tests/Example.OpenApi32/Example.OpenApi32.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + enable + enable + Example.OpenApi32 + + + + + + + + + + + + + + + diff --git a/tests/Example.OpenApi32/Paths/FooFooId/Delete/Operation.Handler.cs b/tests/Example.OpenApi32/Paths/FooFooId/Delete/Operation.Handler.cs new file mode 100644 index 0000000..57a5ad6 --- /dev/null +++ b/tests/Example.OpenApi32/Paths/FooFooId/Delete/Operation.Handler.cs @@ -0,0 +1,10 @@ +namespace Example.OpenApi32.Paths.FooFooId.Delete; + +internal partial class Operation +{ + internal partial Task HandleAsync(Request request, CancellationToken cancellationToken) + { + var response = new Response.OK200(); + return Task.FromResult(response); + } +} diff --git a/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs b/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs new file mode 100644 index 0000000..6333da1 --- /dev/null +++ b/tests/Example.OpenApi32/Paths/FooFooId/Put/Operation.Handler.cs @@ -0,0 +1,39 @@ +using System.Collections.Immutable; +using Corvus.Json; + +namespace Example.OpenApi32.Paths.FooFooId.Put; + +internal partial class Operation +{ + public Operation() + { + HandleRequestValidationError = HandleValidationErrors; + } + + private static Response.BadRequest400 HandleValidationErrors(ImmutableList validationResults) + { + var response = validationResults.Select(result => + Components.Responses.BadRequest.Content.ApplicationJson.RequiredErrorAndName.Create( + name: result.Location?.SchemaLocation.ToString() ?? string.Empty, + error: result.Message ?? string.Empty)); + return new Response.BadRequest400( + Components.Responses.BadRequest.Content.ApplicationJson.Create(response.ToArray())); + } + + internal partial Task HandleAsync(Request request, CancellationToken cancellationToken) + { + _ = request.Query.Fee; + _ = request.Path.FooId; + _ = request.Header.Bar; + + var response = new Response.OK200(Components.Schemas.FooProperties.Create( + name: request.Body.ApplicationJson?.Name)) + { + Headers = new Response.OK200.ResponseHeaders + { + Status = 2 + } + }; + return Task.FromResult(response); + } +} diff --git a/tests/Example.OpenApi32/Program.cs b/tests/Example.OpenApi32/Program.cs new file mode 100644 index 0000000..4d99941 --- /dev/null +++ b/tests/Example.OpenApi32/Program.cs @@ -0,0 +1,9 @@ +using Example.OpenApi32; + +var builder = WebApplication.CreateBuilder(args); +builder.AddOperations(builder.Configuration.Get()); +var app = builder.Build(); +app.MapOperations(); +app.Run(); + +public abstract partial class Program; diff --git a/tests/Example.OpenApi32/appsettings.Development.json b/tests/Example.OpenApi32/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/tests/Example.OpenApi32/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/tests/Example.OpenApi32/appsettings.json b/tests/Example.OpenApi32/appsettings.json new file mode 100644 index 0000000..1f24cdc --- /dev/null +++ b/tests/Example.OpenApi32/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "OpenApiSpecificationUri": "https://localhost/api.json" +} diff --git a/tests/Example.OpenApi32/openapi.json b/tests/Example.OpenApi32/openapi.json new file mode 100644 index 0000000..df8f3a6 --- /dev/null +++ b/tests/Example.OpenApi32/openapi.json @@ -0,0 +1,147 @@ +{ + "openapi": "3.2.0", + "info": { + "title": "Example API", + "version": "2025-11-05", + "description": "Foo API" + }, + "paths": { + "/foo/{FooId}": { + "put": { + "operationId": "Update_Foo", + "parameters": [ + { + "$ref": "#/components/parameters/Bar" + }, + { + "name": "Fee", + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "required": false, + "style": "form", + "explode": true + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/FooProperties" + }, + "responses": { + "200": { + "description": "Successfully updated", + "headers": { + "Status": { + "description": "The Status of foo", + "schema": { + "type": "integer" + } + }, + "Tag": { + "description": "An optional tag", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FooProperties" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + } + }, + "delete": { + "operationId": "Delete_Foo", + "responses": { + "200": { + "description": "Successfully deleted" + } + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/FooId" + } + ] + } + }, + "components": { + "schemas": { + "FooProperties": { + "description": "Foo properties.", + "type": "object", + "properties": { + "Name": { + "description": "Name of foo", + "type": "string" + } + } + } + }, + "parameters": { + "Bar": { + "name": "Bar", + "in": "header", + "schema": { + "type": "string" + }, + "required": true + }, + "FooId": { + "name": "FooId", + "in": "path", + "schema": { + "type": "integer" + }, + "required": true + } + }, + "requestBodies": { + "FooProperties": { + "description": "Foo properties.", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FooProperties" + } + } + } + } + }, + "responses": { + "BadRequest": { + "description": "Returned when the request has validation errors", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "required": ["name", "error"] + } + } + } + } + } + } + } +} diff --git a/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.TheoryData.NoResponseContentSpecs.cs b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.TheoryData.NoResponseContentSpecs.cs new file mode 100644 index 0000000..46c4a69 --- /dev/null +++ b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.TheoryData.NoResponseContentSpecs.cs @@ -0,0 +1,86 @@ +using Xunit; + +namespace OpenAPI.WebApiGenerator.Tests; + +public partial class ApiGeneratorTests +{ + public static TheoryData NoResponseContentSpecs => new() + { + { + "Swagger 2.0", + """ + { + "swagger": "2.0", + "info": { "title": "foo", "version": "1.0" }, + "paths": { + "/foo": { + "delete": { + "operationId": "Delete", + "responses": { + "202": { "description": "Success" } + } + } + } + } + } + """ + }, + { + "OpenAPI 3.0", + """ + { + "openapi": "3.0.3", + "info": { "title": "foo", "version": "1.0" }, + "paths": { + "/foo": { + "delete": { + "operationId": "Delete", + "responses": { + "202": { "description": "Success" } + } + } + } + } + } + """ + }, + { + "OpenAPI 3.1", + """ + { + "openapi": "3.1.0", + "info": { "title": "foo", "version": "1.0" }, + "paths": { + "/foo": { + "delete": { + "operationId": "Delete", + "responses": { + "202": { "description": "Success" } + } + } + } + } + } + """ + }, + { + "OpenAPI 3.2", + """ + { + "openapi": "3.2.0", + "info": { "title": "foo", "version": "1.0" }, + "paths": { + "/foo": { + "delete": { + "operationId": "Delete", + "responses": { + "202": { "description": "Success" } + } + } + } + } + } + """ + } + }; +} \ No newline at end of file diff --git a/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.TheoryData.OpenApiSpecsWithOperations.cs b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.TheoryData.OpenApiSpecsWithOperations.cs new file mode 100644 index 0000000..73f6ac6 --- /dev/null +++ b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.TheoryData.OpenApiSpecsWithOperations.cs @@ -0,0 +1,138 @@ +using Xunit; + +namespace OpenAPI.WebApiGenerator.Tests; + +public partial class ApiGeneratorTests +{ + public static TheoryData OpenApiSpecsWithOperations => new() + { + { + "Swagger 2.0", + """ + { + "swagger": "2.0", + "info": { "title": "foo", "version": "1.0" }, + "paths": { + "/foo": { + "put": { + "operationId": "Service_SetProperties", + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "HourMetrics": { "type": "string" } + } + } + } + ], + "responses": { + "202": { "description": "Success (Accepted)" } + } + } + } + } + } + """ + }, + { + "OpenAPI 3.0", + """ + { + "openapi": "3.0.3", + "info": { "title": "foo", "version": "1.0" }, + "paths": { + "/foo": { + "put": { + "operationId": "Service_SetProperties", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "HourMetrics": { "type": "string" } + } + } + } + } + }, + "responses": { + "202": { "description": "Success (Accepted)" } + } + } + } + } + } + """ + }, + { + "OpenAPI 3.1", + """ + { + "openapi": "3.1.0", + "info": { "title": "foo", "version": "1.0" }, + "paths": { + "/foo": { + "put": { + "operationId": "Service_SetProperties", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "HourMetrics": { "type": "string" } + } + } + } + } + }, + "responses": { + "202": { "description": "Success (Accepted)" } + } + } + } + } + } + """ + }, + { + "OpenAPI 3.2", + """ + { + "openapi": "3.2.0", + "info": { "title": "foo", "version": "1.0" }, + "paths": { + "/foo": { + "put": { + "operationId": "Service_SetProperties", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "HourMetrics": { "type": "string" } + } + } + } + } + }, + "responses": { + "202": { "description": "Success (Accepted)" } + } + } + } + } + } + """ + } + }; +} \ No newline at end of file diff --git a/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs index 7f3301f..ea76d4d 100644 --- a/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs +++ b/tests/OpenAPI.WebApiGenerator.Tests/ApiGeneratorTests.cs @@ -12,7 +12,7 @@ namespace OpenAPI.WebApiGenerator.Tests; -public class ApiGeneratorTests +public partial class ApiGeneratorTests { private CancellationToken Cancellation => TestContext.Current.CancellationToken; @@ -20,6 +20,7 @@ public class ApiGeneratorTests [InlineData("openapi-v2.json")] [InlineData("openapi-v3.json")] [InlineData("openapi-v3.1.json")] + [InlineData("openapi-v3.2.json")] public void GivenAnOpenAPISpec_WhenGeneratingAPI_ExpectedClassesShouldHaveBeenGenerated(string specFile) { var generator = new ApiGenerator(); @@ -52,8 +53,11 @@ public void GivenAnOpenAPISpec_WhenGeneratingAPI_ExpectedClassesShouldHaveBeenGe generatedFiles.Should().ContainMatch("*.Operation.g.cs"); } - [Fact] - public void GivenAImplementedOperation_WhenGeneratingAPI_NoOperationHandlerStubsShouldBeGenerated() + + [Theory] + [MemberData(nameof(OpenApiSpecsWithOperations))] + public void GivenAImplementedOperation_WhenGeneratingAPI_NoOperationHandlerStubsShouldBeGenerated( + string _, string openApiSpec) { var generator = new ApiGenerator(); @@ -61,46 +65,7 @@ public void GivenAImplementedOperation_WhenGeneratingAPI_NoOperationHandlerStubs driver = driver.AddAdditionalTexts( [ - new InMemoryAdditionalText("openapi.json", - """ - { - "swagger": "2.0", - "info": { - "title": "foo", - "version": "1.0" - }, - "paths": { - "/foo": { - "put": { - "operationId": "Service_SetProperties", - "description": "Sets properties for a storage account's File service endpoint, including properties for Storage Analytics metrics and CORS (Cross-Origin Resource Sharing) rules.", - "parameters": [ - { - "name": "StorageServiceProperties", - "in": "body", - "description": "The StorageService properties.", - "required": true, - "schema": { - "description": "Storage service properties.", - "type": "object", - "properties": { - "HourMetrics": { - "description": "A summary of request statistics grouped by API in hourly aggregates for files.", - "type": "string" - } - } - } - }], - "responses": { - "202": { - "description": "Success (Accepted)" - } - } - } - } - } - } - """) + new InMemoryAdditionalText("openapi.json", openApiSpec) ] ); @@ -156,32 +121,11 @@ internal partial Task HandleAsync(Request request, CancellationToken c generatedFiles.Should().HaveCountGreaterThan(0); } - [Fact] - public void NoResponseContent_Generating_DefaultResponseConstructor() + [Theory] + [MemberData(nameof(NoResponseContentSpecs))] + public void NoResponseContent_Generating_DefaultResponseConstructor(string _, string openApiSpec) { - const string openApiSpec = -""" -{ - "swagger": "2.0", - "info": { - "title": "foo", - "version": "1.0" - }, - "paths": { - "/foo": { - "delete": { - "operationId": "Delete", - "responses": { - "202": { - "description": "Success" - } - } - } - } - } -} -"""; - var compilation = SetupGenerator(openApiSpec, + var compilation = SetupGenerator(openApiSpec, out var diagnostics); HasOnlyMissingHandler(diagnostics); compilation.SyntaxTrees.Should().HaveCountGreaterThan(0); @@ -194,7 +138,7 @@ public void NoResponseContent_Generating_DefaultResponseConstructor() .Parameters.Should().HaveCount(0); } - private void HasOnlyMissingHandler(ImmutableArray diagnostics) + private static void HasOnlyMissingHandler(ImmutableArray diagnostics) { diagnostics.Should().AllSatisfy(diagnostic => { @@ -224,5 +168,4 @@ private Compilation SetupGenerator(string openApiSpec, out ImmutableArraySetting Timeouts for File Service Operations.", - "required": false, - "type": "integer", - "minimum": 0, - "x-ms-parameter-location": "method" - }, - "SharePermission": { - "name": "sharePermission", - "in": "body", - "description": "A permission (a security descriptor) at the share level.", - "required": true, - "schema": { - "$ref": "#/definitions/SharePermission" - }, - "x-ms-parameter-location": "method" - }, - "ShareEnabledProtocols": { - "name": "x-ms-enabled-protocols", - "description": "Protocols to enable on the share.", - "x-ms-client-name": "enabledProtocols", - "in": "header", - "required": false, - "type": "string", - "x-ms-parameter-location": "method" - }, - "ShareRootSquash": { - "name": "x-ms-root-squash", - "description": "Root squash to set on the share. Only valid for NFS shares.", - "x-ms-client-name": "rootSquash", - "in": "header", - "required": false, - "type": "string", - "enum": [ - "NoRootSquash", - "RootSquash", - "AllSquash" - ], - "x-ms-enum": { - "name": "ShareRootSquash", - "modelAsString": false - }, - "x-ms-parameter-location": "method" - }, - "AllowTrailingDot": { - "name": "x-ms-allow-trailing-dot", - "description": "If true, the trailing dot will not be trimmed from the target URI.", - "x-ms-client-name": "allowTrailingDot", - "in": "header", - "required": false, - "type": "boolean" - }, - "SourceAllowTrailingDot": { - "name": "x-ms-source-allow-trailing-dot", - "description": "If true, the trailing dot will not be trimmed from the source URI.", - "x-ms-client-name": "allowSourceTrailingDot", - "in": "header", - "required": false, - "type": "boolean" - }, - "Owner": { - "name": "x-ms-owner", - "x-ms-client-name": "owner", - "in": "header", - "required": false, - "type": "string", - "description": "Optional, NFS only. The owner of the file or directory.", - "x-ms-parameter-location": "method" - }, - "Group": { - "name": "x-ms-group", - "x-ms-client-name": "group", - "in": "header", - "required": false, - "type": "string", - "description": "Optional, NFS only. The owning group of the file or directory.", - "x-ms-parameter-location": "method" - }, - "FileMode": { - "name": "x-ms-mode", - "x-ms-client-name": "fileMode", - "in": "header", - "required": false, - "type": "string", - "description": "Optional, NFS only. The file mode of the file or directory", - "x-ms-parameter-location": "method" - }, - "NfsFileType": { - "name": "x-ms-file-file-type", - "x-ms-client-name": "nfsFileType", - "in": "header", - "required": false, - "description": "Optional, NFS only. Type of the file or directory.", - "type": "string", - "enum": [ - "Regular", - "Directory", - "SymLink" - ], - "x-ms-enum": { - "name": "NfsFileType", - "modelAsString": true - }, - "x-ms-parameter-location": "method" - }, - "LinkText": { - "name": "x-ms-link-text", - "x-ms-client-name": "linkText", - "in": "header", - "required": true, - "description": "NFS only. Required. The path to the original file, the symbolic link is pointing to. The path is of type string which is not resolved and is stored as is. The path can be absolute path or the relative path depending on the content stored in the symbolic link file.", - "type": "string", - "x-ms-parameter-location": "method" + "securityDefinitions": { + "bearerAuth": { + "type": "apiKey", + "name": "Authorization", + "in": "header" }, - "TargetFile": { - "name": "x-ms-file-target-file", - "x-ms-client-name": "targetFile", + "apiKey": { + "type": "apiKey", "in": "header", - "required": true, - "description": "NFS only. Required. Specifies the path of the target file to which the link will be created, up to 2 KiB in length. It should be full path of the target from the root.The target file must be in the same share and hence the same storage account.", - "type": "string", - "x-ms-parameter-location": "method" + "name": "X-Api-Key" + } + }, + "security": [ + { + "bearerAuth": [] } - } -} + ] +} \ No newline at end of file diff --git a/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.2.json b/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.2.json new file mode 100644 index 0000000..60f2112 --- /dev/null +++ b/tests/OpenAPI.WebApiGenerator.Tests/OpenApiSpecs/openapi-v3.2.json @@ -0,0 +1,865 @@ +{ + "openapi": "3.2.0", + "info": { + "title": "Comprehensive Test API", + "description": "An exhaustive OpenAPI 3.2 specification for testing code generation", + "version": "1.0.0", + "contact": { + "name": "Test", + "email": "test@example.com" + }, + "license": { + "name": "MIT", + "identifier": "MIT" + } + }, + "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema", + "servers": [ + { + "url": "https://api.example.com/v1" + } + ], + "tags": [ + {"name": "pets", "description": "Pet operations"}, + {"name": "store", "description": "Store operations"}, + {"name": "users", "description": "User operations"} + ], + "paths": { + "/pets": { + "get": { + "operationId": "listPets", + "summary": "List all pets", + "tags": ["pets"], + "parameters": [ + {"$ref": "#/components/parameters/LimitParam"}, + {"$ref": "#/components/parameters/OffsetParam"}, + { + "name": "status", + "in": "query", + "description": "Filter by status", + "required": false, + "schema": { + "$ref": "#/components/schemas/PetStatus" + } + }, + { + "name": "tags", + "in": "query", + "description": "Filter by tags", + "required": false, + "style": "form", + "explode": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + {"$ref": "#/components/parameters/RequestIdHeader"} + ], + "responses": { + "200": { + "description": "A list of pets", + "headers": { + "X-Total-Count": {"$ref": "#/components/headers/TotalCount"}, + "X-Page-Size": { + "description": "Number of items per page", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + } + } + }, + "400": {"$ref": "#/components/responses/BadRequest"}, + "default": { + "description": "Unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "createPet", + "summary": "Create a pet", + "tags": ["pets"], + "requestBody": {"$ref": "#/components/requestBodies/NewPetBody"}, + "responses": { + "201": { + "description": "Pet created", + "headers": { + "Location": { + "description": "URL of created pet", + "schema": { + "type": "string", + "format": "uri" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "400": {"$ref": "#/components/responses/BadRequest"} + } + } + }, + "/pets/{petId}": { + "parameters": [ + {"$ref": "#/components/parameters/PetIdPath"} + ], + "get": { + "operationId": "getPet", + "summary": "Get a pet by ID", + "tags": ["pets"], + "responses": { + "200": {"$ref": "#/components/responses/PetResponse"}, + "404": {"$ref": "#/components/responses/NotFound"} + } + }, + "put": { + "operationId": "updatePet", + "summary": "Update a pet", + "tags": ["pets"], + "requestBody": { + "description": "Pet data to update", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewPet" + } + } + } + }, + "responses": { + "200": { + "description": "Pet updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "404": {"$ref": "#/components/responses/NotFound"} + } + }, + "delete": { + "operationId": "deletePet", + "summary": "Delete a pet", + "tags": ["pets"], + "parameters": [ + { + "name": "X-Api-Key", + "in": "header", + "description": "API key for authorization", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Pet deleted" + }, + "404": {"$ref": "#/components/responses/NotFound"} + } + } + }, + "/pets/{petId}/image": { + "post": { + "operationId": "uploadPetImage", + "summary": "Upload pet image", + "tags": ["pets"], + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "description": "Image to upload", + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "contentMediaType": "image/*", + "contentEncoding": "binary" + }, + "description": { + "type": "string" + } + }, + "required": ["file"] + } + } + } + }, + "responses": { + "200": { + "description": "Image uploaded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageUploadResponse" + } + } + } + } + } + } + }, + "/store/orders": { + "post": { + "operationId": "placeOrder", + "summary": "Place an order", + "tags": ["store"], + "requestBody": {"$ref": "#/components/requestBodies/OrderBody"}, + "responses": { + "201": {"$ref": "#/components/responses/OrderResponse"}, + "400": { + "description": "Invalid order", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/store/orders/{orderId}": { + "get": { + "operationId": "getOrder", + "summary": "Get order by ID", + "tags": ["store"], + "parameters": [ + {"$ref": "#/components/parameters/OrderIdPath"} + ], + "responses": { + "200": {"$ref": "#/components/responses/OrderResponse"}, + "404": { + "description": "Order not found" + } + } + }, + "delete": { + "operationId": "cancelOrder", + "summary": "Cancel an order", + "tags": ["store"], + "parameters": [ + { + "name": "orderId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Order cancelled" + }, + "404": { + "description": "Order not found" + } + } + } + }, + "/store/inventory": { + "get": { + "operationId": "getInventory", + "summary": "Get store inventory", + "tags": ["store"], + "responses": { + "200": { + "description": "Inventory counts by status", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + } + } + } + } + } + } + }, + "/users": { + "post": { + "operationId": "createUser", + "summary": "Create user", + "tags": ["users"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "responses": { + "201": {"$ref": "#/components/responses/UserResponse"} + } + } + }, + "/users/{username}": { + "parameters": [ + {"$ref": "#/components/parameters/UsernamePath"} + ], + "get": { + "operationId": "getUser", + "summary": "Get user by username", + "tags": ["users"], + "responses": { + "200": {"$ref": "#/components/responses/UserResponse"}, + "404": {"$ref": "#/components/responses/NotFound"} + } + }, + "put": { + "operationId": "updateUser", + "summary": "Update user", + "tags": ["users"], + "requestBody": {"$ref": "#/components/requestBodies/UserBody"}, + "responses": { + "200": { + "description": "User updated" + }, + "404": {"$ref": "#/components/responses/NotFound"} + } + }, + "delete": { + "operationId": "deleteUser", + "summary": "Delete user", + "tags": ["users"], + "parameters": [ + { + "name": "username", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "User deleted" + }, + "404": { + "description": "User not found" + } + } + } + }, + "/users/login": { + "post": { + "operationId": "loginUser", + "summary": "User login", + "tags": ["users"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["username", "password"], + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string", + "format": "password" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Login successful", + "headers": { + "X-Rate-Limit": {"$ref": "#/components/headers/RateLimit"}, + "X-Expires-After": { + "description": "Token expiration time", + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginResponse" + } + } + } + }, + "401": { + "description": "Invalid credentials" + } + } + } + } + }, + "components": { + "schemas": { + "Pet": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "category": { + "$ref": "#/components/schemas/Category" + }, + "photoUrls": { + "type": "array", + "items": { + "type": "string", + "format": "uri" + } + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Tag" + } + }, + "status": { + "$ref": "#/components/schemas/PetStatus" + }, + "metadata": { + "type": ["object", "null"], + "additionalProperties": { + "type": "string" + } + }, + "createdAt": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "readOnly": true + } + } + }, + "NewPet": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "category": { + "$ref": "#/components/schemas/Category" + }, + "photoUrls": { + "type": "array", + "items": { + "type": "string", + "format": "uri" + } + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Tag" + } + }, + "status": { + "type": "string", + "enum": ["available", "pending", "sold"], + "default": "available" + } + } + }, + "PetStatus": { + "type": "string", + "enum": ["available", "pending", "sold"], + "default": "available" + }, + "Category": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + }, + "Tag": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + }, + "Order": { + "type": "object", + "required": ["petId", "quantity"], + "properties": { + "id": { + "type": "string", + "format": "uuid", + "readOnly": true + }, + "petId": { + "type": "integer" + }, + "quantity": { + "type": "integer", + "minimum": 1, + "maximum": 10 + }, + "shipDate": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string", + "enum": ["placed", "approved", "delivered"], + "default": "placed" + }, + "complete": { + "type": "boolean", + "default": false + } + } + }, + "User": { + "type": "object", + "required": ["username", "email"], + "properties": { + "id": { + "type": "integer", + "readOnly": true + }, + "username": { + "type": "string", + "minLength": 1, + "maxLength": 50, + "pattern": "^[a-zA-Z0-9_]+$" + }, + "firstName": { + "type": ["string", "null"] + }, + "lastName": { + "type": ["string", "null"] + }, + "email": { + "type": "string", + "format": "email" + }, + "password": { + "type": "string", + "format": "password", + "writeOnly": true + }, + "phone": { + "type": ["string", "null"] + }, + "userStatus": { + "type": "integer", + "description": "User status" + } + } + }, + "LoginResponse": { + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "expiresIn": { + "type": "integer" + } + } + }, + "ImageUploadResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + } + }, + "Error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "details": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + }, + "parameters": { + "PetIdPath": { + "name": "petId", + "in": "path", + "description": "Pet ID", + "required": true, + "schema": { + "type": "integer" + } + }, + "OrderIdPath": { + "name": "orderId", + "in": "path", + "description": "Order ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + "UsernamePath": { + "name": "username", + "in": "path", + "description": "Username", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 50, + "pattern": "^[a-zA-Z0-9_]+$" + } + }, + "LimitParam": { + "name": "limit", + "in": "query", + "description": "Maximum number of items to return", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + } + }, + "OffsetParam": { + "name": "offset", + "in": "query", + "description": "Number of items to skip", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "default": 0 + } + }, + "RequestIdHeader": { + "name": "X-Request-Id", + "in": "header", + "description": "Request correlation ID", + "required": false, + "schema": { + "type": "string", + "format": "uuid" + } + } + }, + "headers": { + "TotalCount": { + "description": "Total number of items", + "schema": { + "type": "integer" + } + }, + "RateLimit": { + "description": "Calls per hour allowed", + "schema": { + "type": "integer" + } + } + }, + "requestBodies": { + "NewPetBody": { + "description": "Pet to create", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewPet" + } + } + } + }, + "OrderBody": { + "description": "Order to place", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "UserBody": { + "description": "User data", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + }, + "responses": { + "PetResponse": { + "description": "Pet found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "OrderResponse": { + "description": "Order found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "UserResponse": { + "description": "User found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "BadRequest": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "NotFound": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + }, + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-Api-Key" + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] +} \ No newline at end of file