Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/lint-openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@ jobs:

- name: Lint OpenAPI specs
run: |
vacuum lint --globbed-files="*/**/openapi.json"
vacuum lint --globbed-files="tests/{*/,*/*/}openapi*.json"
28 changes: 28 additions & 0 deletions OpenAPI.WebApiGenerator.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 11 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions src/OpenAPI.WebApiGenerator/ApiGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.OpenApi" Version="2.3.0" OutputItemType="Analyzer" PrivateAssets="all" GeneratePathProperty="true" />
<PackageReference Include="Microsoft.OpenApi" Version="3.1.3" OutputItemType="Analyzer" PrivateAssets="all" GeneratePathProperty="true" />
<PackageReference Include="Nullable" Version="1.3.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ namespace OpenAPI.WebApiGenerator.OpenApi;

internal static class OpenApiRequestBodyExtensions
{
internal static IDictionary<string, OpenApiMediaType> GetContent(this IOpenApiRequestBody requestBody) =>
internal static IDictionary<string, IOpenApiMediaType> GetContent(this IOpenApiRequestBody requestBody) =>
requestBody.Content ?? throw new NullReferenceException("Request body content is required");
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<IOpenApiWriter> 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public static IOpenApiVisitor V(OpenApiSpecVersion version, OpenApiReference<Ope
OpenApiSpecVersion.OpenApi2_0 => 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")
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ private sealed class OperationVisitor :
private Dictionary<IOpenApiParameter, JsonReference> _parameterSchemaReferences = new();
private JsonReference? _bodySchemaReference;
private readonly Dictionary<IOpenApiResponse, IOpenApiResponseVisitor> _responseVisitors = new();

private JsonReference? _formDataSchemaReference;

private OperationVisitor(OpenApiReference<OpenApiOperation> openApiReference) : base(openApiReference)
{
VisitParameters();
Expand All @@ -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()
Expand All @@ -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];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ private ParametersVisitor(OpenApiReference<IList<IOpenApiParameter>> openApiRefe

internal Dictionary<IOpenApiParameter, JsonReference> Schemas { get; } = new();
internal JsonReference? BodySchema { get; private set; }
internal JsonReference? FormData { get; private set; }

internal static ParametersVisitor Visit(OpenApiReference<IList<IOpenApiParameter>> openApiReference) =>
new(openApiReference);
Expand Down Expand Up @@ -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++;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ private sealed class OperationVisitor :
{
private Dictionary<IOpenApiParameter, JsonReference> _parameterSchemaReferences = new();
private readonly Dictionary<IOpenApiResponse, IOpenApiResponseVisitor> _responseVisitors = new();
private readonly Dictionary<OpenApiMediaType, JsonReference> _requestContentSchemaReferences = new();
private readonly Dictionary<IOpenApiMediaType, JsonReference> _requestContentSchemaReferences = new();

private OperationVisitor(OpenApiReference<OpenApiOperation> openApiReference) : base(openApiReference)
{
Expand Down Expand Up @@ -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];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ private ResponseVisitor(OpenApiReference<IOpenApiResponse> openApiReference) : b
}

private readonly Dictionary<IOpenApiHeader, JsonReference> _headerReferences = new();
private readonly Dictionary<OpenApiMediaType, JsonReference> _contentReferences = new();
private readonly Dictionary<IOpenApiMediaType, JsonReference> _contentReferences = new();

internal static ResponseVisitor Visit(OpenApiReference<IOpenApiResponse> openApiReference) =>
new(openApiReference);
Expand Down Expand Up @@ -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) =>
Expand Down
24 changes: 24 additions & 0 deletions tests/Example.OpenApi32.IntegrationTests/DeleteFooTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System.Net;
using AwesomeAssertions;

namespace Example.OpenApi32.IntegrationTests;

public class DeleteFooTests(FooApplicationFactory app) : FooTestSpecification, IClassFixture<FooApplicationFactory>
{
[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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="AwesomeAssertions" Version="9.2.1" />
<PackageReference Include="coverlet.collector" Version="6.0.2"/>
<PackageReference Include="JetBrains.Annotations" Version="2025.2.2" />
<PackageReference Include="JsonPointer.Net" Version="5.3.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="xunit.v3" Version="3.1.0" />
</ItemGroup>

<ItemGroup>
<Using Include="Xunit"/>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Example.OpenApi32\Example.OpenApi32.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
using JetBrains.Annotations;
using Microsoft.AspNetCore.Mvc.Testing;

namespace Example.OpenApi32.IntegrationTests;

[UsedImplicitly]
public class FooApplicationFactory : WebApplicationFactory<Program>;
Original file line number Diff line number Diff line change
@@ -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");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.Text.Json.Nodes;

namespace Example.OpenApi32.IntegrationTests.Http;

internal static class HttpContentExtensions
{
internal static async Task<JsonNode?> ReadAsJsonNodeAsync(this HttpContent content,
CancellationToken cancellationToken) =>
await JsonNode.ParseAsync(
await content.ReadAsStreamAsync(cancellationToken)
.ConfigureAwait(false),
cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
Loading