diff --git a/.vscode/launch.json b/.vscode/launch.json index 149c5b96..af0ffb2e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,7 +6,7 @@ "type": "coreclr", "request": "launch", "preLaunchTask": "build", - "program": "${workspaceFolder}/src/TodoApp/bin/Debug/net9.0/TodoApp.dll", + "program": "${workspaceFolder}/src/TodoApp/bin/Debug/net10.0/TodoApp.dll", "args": [], "cwd": "${workspaceFolder}/src/TodoApp", "stopAtEntry": false, diff --git a/.vsconfig b/.vsconfig index 96ff329c..99efa2ec 100644 --- a/.vsconfig +++ b/.vsconfig @@ -2,7 +2,7 @@ "version": "1.0", "components": [ "Component.Microsoft.VisualStudio.RazorExtension", - "Microsoft.NetCore.Component.Runtime.9.0", + "Microsoft.NetCore.Component.Runtime.10.0", "Microsoft.NetCore.Component.SDK", "Microsoft.VisualStudio.Component.CoreEditor", "Microsoft.VisualStudio.Component.JavaScript.Diagnostics", diff --git a/NuGet.config b/NuGet.config index 38ac8e75..dc1b1613 100644 --- a/NuGet.config +++ b/NuGet.config @@ -2,9 +2,13 @@ + + + + diff --git a/global.json b/global.json index 3c79dbb0..60128891 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "9.0.306", + "version": "10.0.100-rc.2.25502.107", "allowPrerelease": false, "rollForward": "latestMajor" } diff --git a/perf/TodoApp.Benchmarks/TodoApp.Benchmarks.csproj b/perf/TodoApp.Benchmarks/TodoApp.Benchmarks.csproj index 2007bd5a..966e1848 100644 --- a/perf/TodoApp.Benchmarks/TodoApp.Benchmarks.csproj +++ b/perf/TodoApp.Benchmarks/TodoApp.Benchmarks.csproj @@ -2,7 +2,7 @@ Exe TodoApp - net9.0 + net10.0 diff --git a/src/TodoApp/OpenApi/AspNetCore/AddExamplesTransformer.cs b/src/TodoApp/OpenApi/AspNetCore/AddExamplesTransformer.cs index fa04923f..9af51dbc 100644 --- a/src/TodoApp/OpenApi/AspNetCore/AddExamplesTransformer.cs +++ b/src/TodoApp/OpenApi/AspNetCore/AddExamplesTransformer.cs @@ -2,7 +2,7 @@ // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. using Microsoft.AspNetCore.OpenApi; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; namespace TodoApp.OpenApi.AspNetCore; diff --git a/src/TodoApp/OpenApi/AspNetCore/AddSchemaDescriptionsTransformer.cs b/src/TodoApp/OpenApi/AspNetCore/AddSchemaDescriptionsTransformer.cs index 311b8c89..2f111d90 100644 --- a/src/TodoApp/OpenApi/AspNetCore/AddSchemaDescriptionsTransformer.cs +++ b/src/TodoApp/OpenApi/AspNetCore/AddSchemaDescriptionsTransformer.cs @@ -7,7 +7,7 @@ using System.Xml; using System.Xml.XPath; using Microsoft.AspNetCore.OpenApi; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; namespace TodoApp.OpenApi.AspNetCore; diff --git a/src/TodoApp/OpenApi/AspNetCore/AspNetCoreOpenApiEndpoints.cs b/src/TodoApp/OpenApi/AspNetCore/AspNetCoreOpenApiEndpoints.cs index c4f4f921..4d96422d 100644 --- a/src/TodoApp/OpenApi/AspNetCore/AspNetCoreOpenApiEndpoints.cs +++ b/src/TodoApp/OpenApi/AspNetCore/AspNetCoreOpenApiEndpoints.cs @@ -1,7 +1,7 @@ // Copyright (c) Martin Costello, 2024. All rights reserved. // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; namespace TodoApp.OpenApi.AspNetCore; @@ -14,6 +14,9 @@ public static IServiceCollection AddAspNetCoreOpenApi(this IServiceCollection se // Add a document transformer to customise the generated OpenAPI document options.AddDocumentTransformer((document, _, _) => { + // TODO Use 3.1 when all three OpenAPI implementations support it + options.OpenApiVersion = Microsoft.OpenApi.OpenApiSpecVersion.OpenApi3_0; + // Add a title and version for the OpenAPI document document.Info.Title = "Todo API (ASP.NET Core OpenAPI)"; document.Info.Description = "An API for managing Todo items."; @@ -39,18 +42,16 @@ public static IServiceCollection AddAspNetCoreOpenApi(this IServiceCollection se Description = "Bearer authentication using a JWT.", Scheme = "bearer", Type = SecuritySchemeType.Http, - Reference = new() - { - Id = "Bearer", - Type = ReferenceType.SecurityScheme, - }, }; + var referenceId = "Bearer"; + var reference = new OpenApiSecuritySchemeReference(referenceId, document); + document.Components ??= new(); - document.Components.SecuritySchemes ??= new Dictionary(); - document.Components.SecuritySchemes[scheme.Reference.Id] = scheme; - document.SecurityRequirements ??= []; - document.SecurityRequirements.Add(new() { [scheme] = [] }); + document.Components.SecuritySchemes ??= new Dictionary(); + document.Components.SecuritySchemes[referenceId] = scheme; + document.Security ??= []; + document.Security.Add(new() { [reference] = [] }); return Task.CompletedTask; }); diff --git a/src/TodoApp/OpenApi/ExampleFormatter.cs b/src/TodoApp/OpenApi/ExampleFormatter.cs index e56c62e1..915b8bb1 100644 --- a/src/TodoApp/OpenApi/ExampleFormatter.cs +++ b/src/TodoApp/OpenApi/ExampleFormatter.cs @@ -2,8 +2,8 @@ // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; -using Microsoft.OpenApi.Any; namespace TodoApp.OpenApi; @@ -19,9 +19,9 @@ internal static class ExampleFormatter /// The type of the example provider. /// The JSON serializer context to use. /// - /// The to use as the example. + /// The to use as the example. /// - public static IOpenApiAny AsJson(JsonSerializerContext context) + public static JsonNode? AsJson(JsonSerializerContext context) where TProvider : IExampleProvider => AsJson(TProvider.GenerateExample(), context); @@ -32,87 +32,12 @@ public static IOpenApiAny AsJson(JsonSerializerContext conte /// The example value to format as JSON. /// The JSON serializer context to use. /// - /// The to use as the example. + /// The to use as the example. /// - public static IOpenApiAny AsJson(T example, JsonSerializerContext context) + public static JsonNode? AsJson(T example, JsonSerializerContext context) { // Apply any formatting rules configured for the API (e.g. camel casing) var json = JsonSerializer.Serialize(example, typeof(T), context); - using var document = JsonDocument.Parse(json); - - if (document.RootElement.ValueKind == JsonValueKind.String) - { - return new OpenApiString(document.RootElement.ToString()); - } - - var result = new OpenApiObject(); - - // Recursively build up the example from the properties of the object - foreach (var token in document.RootElement.EnumerateObject()) - { - if (TryParse(token.Value, out var any)) - { - result[token.Name] = any; - } - } - - return result; - } - - private static bool TryParse(JsonElement token, out IOpenApiAny? any) - { - any = null; - - switch (token.ValueKind) - { - case JsonValueKind.Array: - var array = new OpenApiArray(); - - foreach (var value in token.EnumerateArray()) - { - if (TryParse(value, out var child)) - { - array.Add(child); - } - } - - any = array; - return true; - - case JsonValueKind.False: - any = new OpenApiBoolean(false); - return true; - - case JsonValueKind.True: - any = new OpenApiBoolean(true); - return true; - - case JsonValueKind.Number: - any = new OpenApiDouble(token.GetDouble()); - return true; - - case JsonValueKind.String: - any = new OpenApiString(token.GetString()); - return true; - - case JsonValueKind.Object: - var obj = new OpenApiObject(); - - foreach (var child in token.EnumerateObject()) - { - if (TryParse(child.Value, out var value)) - { - obj[child.Name] = value; - } - } - - any = obj; - return true; - - case JsonValueKind.Null: - case JsonValueKind.Undefined: - default: - return false; - } + return JsonNode.Parse(json); } } diff --git a/src/TodoApp/OpenApi/ExamplesProcessor.cs b/src/TodoApp/OpenApi/ExamplesProcessor.cs index 5a9f230e..aa79d90a 100644 --- a/src/TodoApp/OpenApi/ExamplesProcessor.cs +++ b/src/TodoApp/OpenApi/ExamplesProcessor.cs @@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; namespace TodoApp.OpenApi; @@ -56,7 +56,7 @@ protected void Process(OpenApiSchema schema, Type type) } private static void TryAddParameterExamples( - IList parameters, + IList parameters, ApiDescription description, IList examples) { @@ -89,11 +89,14 @@ private static void TryAddParameterExamples( } private static void TryAddRequestExamples( - OpenApiRequestBody body, + IOpenApiRequestBody body, ApiDescription description, IList examples) { - if (!body.Content.TryGetValue("application/json", out var mediaType) || mediaType.Example is not null) + if (body is null || + body.Content is null || + !body.Content.TryGetValue("application/json", out var mediaType) || + mediaType.Example is not null) { return; } @@ -130,7 +133,7 @@ private static void TryAddResponseExamples( foreach (var responseFormat in schemaResponse.ApiResponseFormats) { if (responses.TryGetValue(schemaResponse.StatusCode.ToString(CultureInfo.InvariantCulture), out var response) && - response.Content.TryGetValue(responseFormat.MediaType, out var mediaType)) + response.Content?.TryGetValue(responseFormat.MediaType, out var mediaType) is true) { mediaType.Example ??= (metadata ?? examples.SingleOrDefault((p) => p.SchemaType == schemaResponse.Type))?.GenerateExample(Context); } diff --git a/src/TodoApp/OpenApi/IOpenApiExampleMetadata.cs b/src/TodoApp/OpenApi/IOpenApiExampleMetadata.cs index 1af9d26e..b71d6761 100644 --- a/src/TodoApp/OpenApi/IOpenApiExampleMetadata.cs +++ b/src/TodoApp/OpenApi/IOpenApiExampleMetadata.cs @@ -1,8 +1,8 @@ // Copyright (c) Martin Costello, 2024. All rights reserved. // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. +using System.Text.Json.Nodes; using System.Text.Json.Serialization; -using Microsoft.OpenApi.Any; namespace TodoApp.OpenApi; @@ -29,7 +29,7 @@ public interface IOpenApiExampleMetadata /// /// The JSON serializer context to use to generate the example. /// - /// The OpenAPI example to use. + /// The OpenAPI example to use, if any. /// - IOpenApiAny GenerateExample(JsonSerializerContext context); + JsonNode? GenerateExample(JsonSerializerContext context); } diff --git a/src/TodoApp/OpenApi/OpenApiExampleAttribute`2.cs b/src/TodoApp/OpenApi/OpenApiExampleAttribute`2.cs index 3b1a1241..54d4c74d 100644 --- a/src/TodoApp/OpenApi/OpenApiExampleAttribute`2.cs +++ b/src/TodoApp/OpenApi/OpenApiExampleAttribute`2.cs @@ -1,8 +1,8 @@ // Copyright (c) Martin Costello, 2024. All rights reserved. // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. +using System.Text.Json.Nodes; using System.Text.Json.Serialization; -using Microsoft.OpenApi.Any; namespace TodoApp.OpenApi; @@ -30,6 +30,6 @@ public class OpenApiExampleAttribute : Attribute, IOpenApiEx object? IOpenApiExampleMetadata.GenerateExample() => GenerateExample(); /// - IOpenApiAny IOpenApiExampleMetadata.GenerateExample(JsonSerializerContext context) + JsonNode? IOpenApiExampleMetadata.GenerateExample(JsonSerializerContext context) => ExampleFormatter.AsJson(GenerateExample(), context); } diff --git a/src/TodoApp/OpenApi/Swashbuckle/AddDocumentTagsFilter.cs b/src/TodoApp/OpenApi/Swashbuckle/AddDocumentTagsFilter.cs index 837bd683..87572d3e 100644 --- a/src/TodoApp/OpenApi/Swashbuckle/AddDocumentTagsFilter.cs +++ b/src/TodoApp/OpenApi/Swashbuckle/AddDocumentTagsFilter.cs @@ -1,7 +1,7 @@ // Copyright (c) Martin Costello, 2024. All rights reserved. // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using Swashbuckle.AspNetCore.SwaggerGen; namespace TodoApp.OpenApi.Swashbuckle; @@ -13,7 +13,7 @@ public class AddDocumentTagsFilter : IDocumentFilter { public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) { - swaggerDoc.Tags ??= []; + swaggerDoc.Tags ??= new HashSet(); swaggerDoc.Tags.Add(new() { Name = "TodoApp" }); } } diff --git a/src/TodoApp/OpenApi/Swashbuckle/AddServersFilter.cs b/src/TodoApp/OpenApi/Swashbuckle/AddServersFilter.cs index 52cb2748..f124f2af 100644 --- a/src/TodoApp/OpenApi/Swashbuckle/AddServersFilter.cs +++ b/src/TodoApp/OpenApi/Swashbuckle/AddServersFilter.cs @@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using Swashbuckle.AspNetCore.SwaggerGen; namespace TodoApp.OpenApi.Swashbuckle; diff --git a/src/TodoApp/OpenApi/Swashbuckle/ExampleFilter.cs b/src/TodoApp/OpenApi/Swashbuckle/ExampleFilter.cs index 53dd0cda..281899eb 100644 --- a/src/TodoApp/OpenApi/Swashbuckle/ExampleFilter.cs +++ b/src/TodoApp/OpenApi/Swashbuckle/ExampleFilter.cs @@ -1,7 +1,7 @@ // Copyright (c) Martin Costello, 2024. All rights reserved. // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using Swashbuckle.AspNetCore.SwaggerGen; namespace TodoApp.OpenApi.Swashbuckle; @@ -16,6 +16,11 @@ public void Apply(OpenApiOperation operation, OperationFilterContext context) => Process(operation, context.ApiDescription); /// - public void Apply(OpenApiSchema schema, SchemaFilterContext context) - => Process(schema, context.Type); + public void Apply(IOpenApiSchema schema, SchemaFilterContext context) + { + if (schema is OpenApiSchema concrete) + { + Process(concrete, context.Type); + } + } } diff --git a/src/TodoApp/OpenApi/Swashbuckle/SwashbuckleOpenApiEndpoints.cs b/src/TodoApp/OpenApi/Swashbuckle/SwashbuckleOpenApiEndpoints.cs index f81822ea..72b8de2d 100644 --- a/src/TodoApp/OpenApi/Swashbuckle/SwashbuckleOpenApiEndpoints.cs +++ b/src/TodoApp/OpenApi/Swashbuckle/SwashbuckleOpenApiEndpoints.cs @@ -1,7 +1,7 @@ // Copyright (c) Martin Costello, 2024. All rights reserved. // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; namespace TodoApp.OpenApi.Swashbuckle; @@ -39,15 +39,10 @@ public static IServiceCollection AddSwashbuckleOpenApi(this IServiceCollection s Description = "Bearer authentication using a JWT.", Scheme = "bearer", Type = SecuritySchemeType.Http, - Reference = new() - { - Id = "Bearer", - Type = ReferenceType.SecurityScheme, - }, - UnresolvedReference = false, }; - options.AddSecurityDefinition(scheme.Reference.Id, scheme); - options.AddSecurityRequirement(new() { [scheme] = [] }); + + options.AddSecurityDefinition("Bearer", scheme); + options.AddSecurityRequirement((document) => new() { [new("Bearer", document)] = [] }); // Enable reading OpenAPI metadata from attributes options.EnableAnnotations(); diff --git a/src/TodoApp/TodoApp.csproj b/src/TodoApp/TodoApp.csproj index ed187c15..4c4f8d9e 100644 --- a/src/TodoApp/TodoApp.csproj +++ b/src/TodoApp/TodoApp.csproj @@ -9,19 +9,19 @@ true --openapi-version OpenApi3_0 TodoApp - net9.0 + net10.0 true latest - - - - + + + + - - + + diff --git a/startvscode.cmd b/startvscode.cmd index d321aa83..91142b50 100644 --- a/startvscode.cmd +++ b/startvscode.cmd @@ -11,7 +11,7 @@ SET DOTNET_ROOT(x86)=%~dp0.dotnet\x86 SET PATH=%DOTNET_ROOT%;%PATH% :: Sets the Target Framework for Visual Studio Code. -SET TARGET=net9.0 +SET TARGET=net10.0 SET FOLDER=%~1 diff --git a/tests/TodoApp.Tests/HttpServerFixture.cs b/tests/TodoApp.Tests/HttpServerFixture.cs index 9f4a1c8a..b1e87037 100644 --- a/tests/TodoApp.Tests/HttpServerFixture.cs +++ b/tests/TodoApp.Tests/HttpServerFixture.cs @@ -1,12 +1,9 @@ // Copyright (c) Martin Costello, 2024. All rights reserved. // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. +using System.Net; using System.Security.Cryptography.X509Certificates; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Hosting.Server; -using Microsoft.AspNetCore.Hosting.Server.Features; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; namespace TodoApp; @@ -16,98 +13,22 @@ namespace TodoApp; /// public sealed class HttpServerFixture : TodoAppFixture { - private bool _disposed; - private IHost? _host; - - public string ServerAddress + public HttpServerFixture() { - get - { - EnsureServer(); - return ClientOptions.BaseAddress.ToString(); - } + // Configure the address for the server to listen on for HTTPS + // requests on a dynamic port. with a self-signed TLS certificate. + UseKestrel( + (server) => server.Listen( + IPAddress.Loopback, 0, (listener) => listener.UseHttps( + (https) => https.ServerCertificate = X509CertificateLoader.LoadPkcs12FromFile("localhost-dev.pfx", "Pa55w0rd!")))); } - public override IServiceProvider Services + public string ServerAddress { get { - EnsureServer(); - return _host!.Services!; - } - } - - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - base.ConfigureWebHost(builder); - - // Configure a self-signed TLS certificate for HTTPS - builder.ConfigureKestrel( - serverOptions => serverOptions.ConfigureHttpsDefaults( - httpsOptions => X509CertificateLoader.LoadPkcs12FromFile("localhost-dev.pfx", "Pa55w0rd!"))); - - // Configure the server address for the server to - // listen on for HTTPS requests on a dynamic port. - builder.UseUrls("https://127.0.0.1:0"); - } - - protected override IHost CreateHost(IHostBuilder builder) - { - // Create the host for TestServer now before we - // modify the builder to use Kestrel instead. - var testHost = builder.Build(); - - // Modify the host builder to use Kestrel instead - // of TestServer so we can listen on a real address. - builder.ConfigureWebHost(webHostBuilder => webHostBuilder.UseKestrel()); - - // Create and start the Kestrel server before the test server, - // otherwise due to the way the deferred host builder works - // for minimal hosting, the server will not get "initialized - // enough" for the address it is listening on to be available. - // See https://github.com/dotnet/aspnetcore/issues/33846. - _host = builder.Build(); - _host.Start(); - - // Extract the selected dynamic port out of the Kestrel server - // and assign it onto the client options for convenience so it - // "just works" as otherwise it'll be the default http://localhost - // URL, which won't route to the Kestrel-hosted HTTP server. - var server = _host.Services.GetRequiredService(); - var addresses = server.Features.Get(); - - ClientOptions.BaseAddress = addresses!.Addresses - .Select(x => new Uri(x)) - .Last(); - - // Return the host that uses TestServer, rather than the real one. - // Otherwise the internals will complain about the host's server - // not being an instance of the concrete type TestServer. - // See https://github.com/dotnet/aspnetcore/pull/34702. - return testHost; - } - - protected override void Dispose(bool disposing) - { - base.Dispose(disposing); - - if (!_disposed) - { - if (disposing) - { - _host?.Dispose(); - } - - _disposed = true; - } - } - - private void EnsureServer() - { - if (_host is null) - { - // This forces WebApplicationFactory to bootstrap the server - using var _ = CreateDefaultClient(); + StartServer(); + return ClientOptions.BaseAddress.ToString(); } } } diff --git a/tests/TodoApp.Tests/OpenApiTests.cs b/tests/TodoApp.Tests/OpenApiTests.cs index 0d51c9a1..800dec24 100644 --- a/tests/TodoApp.Tests/OpenApiTests.cs +++ b/tests/TodoApp.Tests/OpenApiTests.cs @@ -1,9 +1,7 @@ // Copyright (c) Martin Costello, 2024. All rights reserved. // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information. -using Microsoft.OpenApi.Extensions; -using Microsoft.OpenApi.Readers; -using Microsoft.OpenApi.Validations; +using Microsoft.OpenApi; namespace TodoApp; @@ -63,12 +61,15 @@ public async Task Schema_Has_No_Validation_Warnings(string schemaUrl) using var schema = await client.GetStreamAsync(schemaUrl, TestContext.Current.CancellationToken); // Assert - var reader = new OpenApiStreamReader(); - var actual = await reader.ReadAsync(schema, TestContext.Current.CancellationToken); + var actual = await OpenApiDocument.LoadAsync(schema, "json", cancellationToken: TestContext.Current.CancellationToken); - Assert.Empty(actual.OpenApiDiagnostic.Errors); + Assert.NotNull(actual); + Assert.NotNull(actual.Diagnostic); + Assert.Empty(actual.Diagnostic.Errors); + Assert.Empty(actual.Diagnostic.Warnings); + Assert.NotNull(actual.Document); - var errors = actual.OpenApiDocument.Validate(ruleSet); + var errors = actual.Document.Validate(ruleSet); Assert.Empty(errors); } diff --git a/tests/TodoApp.Tests/TodoApp.Tests.csproj b/tests/TodoApp.Tests/TodoApp.Tests.csproj index f47129eb..163d6b1c 100644 --- a/tests/TodoApp.Tests/TodoApp.Tests.csproj +++ b/tests/TodoApp.Tests/TodoApp.Tests.csproj @@ -4,15 +4,14 @@ $(NoWarn);CA1861 Exe TodoApp - net9.0 + net10.0 - + -