diff --git a/Directory.Build.props b/Directory.Build.props index 1e89f23..c81e613 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,11 +1,13 @@ - net8.0;net9.0 - net8.0;net9.0 + net8.0;net9.0;net10.0 + net8.0;net9.0 + net10.0 + net8.0;net9.0;net10.0 - optional partial json undefined jsonpatch jsonmergepatch patch System.Text.Json Api - unspecified + optional partial json undefined jsonpatch jsonmergepatch patch + System.Text.Json Api unspecified diff --git a/Directory.Packages.props b/Directory.Packages.props index 2bdb338..fc81163 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,12 +11,13 @@ + - + @@ -25,6 +26,7 @@ + diff --git a/OptionalValues.slnx b/OptionalValues.slnx index 4c14d16..d79f941 100644 --- a/OptionalValues.slnx +++ b/OptionalValues.slnx @@ -12,6 +12,7 @@ + @@ -19,6 +20,7 @@ + @@ -27,5 +29,7 @@ + + - + \ No newline at end of file diff --git a/examples/OptionalValues.Examples.OpenApi/OptionalValues.Examples.OpenApi.csproj b/examples/OptionalValues.Examples.OpenApi/OptionalValues.Examples.OpenApi.csproj new file mode 100644 index 0000000..4ade081 --- /dev/null +++ b/examples/OptionalValues.Examples.OpenApi/OptionalValues.Examples.OpenApi.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + enable + enable + + false + true + + true + + + + . + --file-name openapi + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + \ No newline at end of file diff --git a/examples/OptionalValues.Examples.OpenApi/Program.cs b/examples/OptionalValues.Examples.OpenApi/Program.cs new file mode 100644 index 0000000..0d3ac8d --- /dev/null +++ b/examples/OptionalValues.Examples.OpenApi/Program.cs @@ -0,0 +1,116 @@ +using System.Text.Json.Serialization; + +using OptionalValues; +using OptionalValues.DataAnnotations; +using OptionalValues.OpenApi; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.Services.AddOpenApi(options => +{ + options.AddOptionalValueSupport(); +}); + +// Add OptionalValue support to the JSON serializer +builder.Services.ConfigureHttpJsonOptions(jsonOptions => +{ + jsonOptions.SerializerOptions.NumberHandling = JsonNumberHandling.Strict; + jsonOptions.SerializerOptions.AddOptionalValueSupport(); +}); + +WebApplication app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.UseHttpsRedirection(); + +// This is an example of how to use OptionalValues with NSwag +// It also shows what the JSON Serializer will do with the OptionalValues +// You can play with the values in the Company object to see how the JSON Serializer will handle them +// Play around by omitting properties or whole objects +app.MapPost("/company", (Company company) => company) + .WithName("Example Post") + .WithDescription("This directly returns the posted object") + .WithTags("Example"); + +app.Run(); + +class NullableProperty +{ + [Specified] + public OptionalValue Name { get; init; } +} + +/// +/// This is the main example company model. +/// +class Company +{ + public Guid Id { get; init; } = Guid.NewGuid(); + + /// + /// Name of the company. + /// + [RequiredValue] + public OptionalValue Name { get; init; } + + [OptionalLength(0, 50)] + public OptionalValue Summary { get; init; } + + /// + /// The contact person for the company. + /// + [Specified] + public OptionalValue Contact { get; init; } +} + +/// +/// This is a person. +/// +class Person +{ + /// + /// The full name of the person. + /// + [Specified] + public OptionalValue Name { get; init; } = "John Doe"; + + [OptionalRange(0, 120)] + public OptionalValue Age { get; init; } + + /// + /// Contact address for the person. + /// + public OptionalValue
Address { get; init; } + + /// + /// Billing address for the person. + /// + public OptionalValue BillingAddress { get; init; } +} + +/// +/// A mailing address. +/// +class Address +{ + [Specified] + public OptionalValue Street { get; init; } + + public OptionalValue City { get; init; } + + [OptionalRegularExpression("^[a-zA-Z ]+$")] + public OptionalValue State { get; init; } + + public OptionalValue Zip { get; init; } +} + +/// +/// Entry point for the OptionalValues.Examples.OpenApi application. +/// +public partial class Program { +} \ No newline at end of file diff --git a/examples/OptionalValues.Examples.OpenApi/Properties/launchSettings.json b/examples/OptionalValues.Examples.OpenApi/Properties/launchSettings.json new file mode 100644 index 0000000..1eaadee --- /dev/null +++ b/examples/OptionalValues.Examples.OpenApi/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7022;http://localhost:5138", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/examples/OptionalValues.Examples.OpenApi/appsettings.json b/examples/OptionalValues.Examples.OpenApi/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/examples/OptionalValues.Examples.OpenApi/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/examples/OptionalValues.Examples.OpenApi/openapi.json b/examples/OptionalValues.Examples.OpenApi/openapi.json new file mode 100644 index 0000000..5da2bdd --- /dev/null +++ b/examples/OptionalValues.Examples.OpenApi/openapi.json @@ -0,0 +1,150 @@ +{ + "openapi": "3.1.1", + "info": { + "title": "OptionalValues.Examples.OpenApi | v1", + "version": "1.0.0" + }, + "paths": { + "/company": { + "post": { + "tags": [ + "Example" + ], + "description": "This directly returns the posted object", + "operationId": "Example Post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Company" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Company" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Address": { + "required": [ + "street" + ], + "type": "object", + "properties": { + "street": { + "type": [ + "null", + "string" + ] + }, + "city": { + "type": "string" + }, + "state": { + "type": "string" + }, + "zip": { + "type": [ + "null", + "string" + ] + } + }, + "description": "A mailing address." + }, + "Company": { + "required": [ + "name", + "contact" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "description": "Name of the company." + }, + "summary": { + "maxLength": 50, + "minLength": 0, + "type": [ + "null", + "string" + ] + }, + "contact": { + "oneOf": [ + { + "type": "null" + }, + { + "description": "The contact person for the company.", + "$ref": "#/components/schemas/Person" + } + ] + } + }, + "description": "This is the main example company model." + }, + "Person": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "type": [ + "null", + "string" + ], + "description": "The full name of the person." + }, + "age": { + "maximum": 120, + "minimum": 0, + "type": "integer", + "format": "int32" + }, + "address": { + "description": "Contact address for the person.", + "$ref": "#/components/schemas/Address" + }, + "billingAddress": { + "oneOf": [ + { + "type": "null" + }, + { + "description": "Billing address for the person.", + "$ref": "#/components/schemas/Address" + } + ] + } + }, + "description": "This is a person." + } + } + }, + "tags": [ + { + "name": "Example" + } + ] +} \ No newline at end of file diff --git a/examples/OptionalValues.Examples.Swashbuckle/OptionalValues.Examples.Swashbuckle.csproj b/examples/OptionalValues.Examples.Swashbuckle/OptionalValues.Examples.Swashbuckle.csproj index 40b5c57..6bcb941 100644 --- a/examples/OptionalValues.Examples.Swashbuckle/OptionalValues.Examples.Swashbuckle.csproj +++ b/examples/OptionalValues.Examples.Swashbuckle/OptionalValues.Examples.Swashbuckle.csproj @@ -10,8 +10,7 @@ - - + diff --git a/examples/OptionalValues.Examples.Swashbuckle/openapi.yaml b/examples/OptionalValues.Examples.Swashbuckle/openapi.yaml index db5149c..8650523 100644 --- a/examples/OptionalValues.Examples.Swashbuckle/openapi.yaml +++ b/examples/OptionalValues.Examples.Swashbuckle/openapi.yaml @@ -1,4 +1,4 @@ -openapi: 3.0.1 +openapi: 3.0.4 info: title: OptionalValues.Examples.Swashbuckle version: '1.0' diff --git a/global.json b/global.json index 345f67e..376af49 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "9.0.306" + "version": "10.0.100" } } diff --git a/src/OptionalValues.OpenApi/OpenApiOptionsExtensions.cs b/src/OptionalValues.OpenApi/OpenApiOptionsExtensions.cs new file mode 100644 index 0000000..8e735f0 --- /dev/null +++ b/src/OptionalValues.OpenApi/OpenApiOptionsExtensions.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Serialization.Metadata; + +using Microsoft.AspNetCore.OpenApi; + +namespace OptionalValues.OpenApi; + +/// +/// Extension methods for to add support for types. +/// +public static class OpenApiOptionsExtensions +{ + /// + /// Adds support for types in OpenAPI generation. + /// This method configures the to correctly handle + /// types by applying a custom schema reference ID + /// creation logic and adding a schema transformer. + /// + /// The OpenApiOptions instance to configure. + public static void AddOptionalValueSupport(this OpenApiOptions options) + { + options.ApplyOptionalValueCreateSchemaReferenceId(); + options.AddSchemaTransformer(); + } + + private static void ApplyOptionalValueCreateSchemaReferenceId(this OpenApiOptions options) + { + Func originalCreateSchemaReferenceId = options.CreateSchemaReferenceId; + + options.CreateSchemaReferenceId = jsonTypeInfo => + { + if (OptionalValue.IsOptionalValueType(jsonTypeInfo.Type)) + { + Type underlyingType = OptionalValue.GetUnderlyingType(jsonTypeInfo.Type); + var underlyingJsonTypeInfo = JsonTypeInfo.CreateJsonTypeInfo(underlyingType, jsonTypeInfo.Options); + + return originalCreateSchemaReferenceId(underlyingJsonTypeInfo); + } + + return originalCreateSchemaReferenceId(jsonTypeInfo); + }; + } +} \ No newline at end of file diff --git a/src/OptionalValues.OpenApi/OptionalValues.OpenApi.csproj b/src/OptionalValues.OpenApi/OptionalValues.OpenApi.csproj new file mode 100644 index 0000000..548dfc9 --- /dev/null +++ b/src/OptionalValues.OpenApi/OptionalValues.OpenApi.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/src/OptionalValues.OpenApi/OptionalValuesSchemaTransformer.cs b/src/OptionalValues.OpenApi/OptionalValuesSchemaTransformer.cs new file mode 100644 index 0000000..ecb4549 --- /dev/null +++ b/src/OptionalValues.OpenApi/OptionalValuesSchemaTransformer.cs @@ -0,0 +1,149 @@ +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using System.Text.Json.Serialization.Metadata; + +using Microsoft.AspNetCore.OpenApi; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi; + +namespace OptionalValues.OpenApi; + +internal class OptionalValuesSchemaTransformer : IOpenApiSchemaTransformer +{ + + public async Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + TransformObjectSchema(schema, context); + await TransformPropertySchema(schema, context, cancellationToken); + } + + private async Task TransformPropertySchema(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + if (context.JsonPropertyInfo == null) + { + return; + } + + if (!OptionalValue.IsOptionalValueType(context.JsonPropertyInfo.PropertyType)) + { + return; + } + + // Get the OpenApiOptions of the current document + IOptionsMonitor openApiOptionsSnapshot = context.ApplicationServices.GetRequiredService>(); + OpenApiOptions openApiOptions = openApiOptionsSnapshot.Get(context.DocumentName); + + Type underlyingType = OptionalValue.GetUnderlyingType(context.JsonPropertyInfo.PropertyType); + var underlyingSchemaId = openApiOptions.CreateSchemaReferenceId( + JsonTypeInfo.CreateJsonTypeInfo(underlyingType, context.JsonTypeInfo.Options)); + var isSchemaReference = !string.IsNullOrEmpty(underlyingSchemaId); + + OpenApiSchema underlyingSchema = await context.GetOrCreateSchemaAsync(underlyingType, cancellationToken: cancellationToken); + + schema.Type = underlyingSchema.Type; + + schema.Format = underlyingSchema.Format; + schema.Properties = underlyingSchema.Properties; + schema.Items = underlyingSchema.Items; + schema.AnyOf = underlyingSchema.AnyOf; + schema.AllOf = underlyingSchema.AllOf; + schema.OneOf = underlyingSchema.OneOf; + schema.Not = underlyingSchema.Not; + schema.AdditionalProperties = underlyingSchema.AdditionalProperties; + schema.Enum = underlyingSchema.Enum; + schema.AdditionalPropertiesAllowed = underlyingSchema.AdditionalPropertiesAllowed; + schema.Required = underlyingSchema.Required; + schema.Pattern = underlyingSchema.Pattern; + + // Merge annotations + schema.Description ??= underlyingSchema.Description; + schema.Default ??= underlyingSchema.Default; + schema.Example ??= underlyingSchema.Example; + + // Merge the metadata + if (underlyingSchema.Metadata is not null) + { + schema.Metadata ??= new Dictionary(); + foreach (KeyValuePair keyValuePair in underlyingSchema.Metadata) + { + schema.Metadata.TryAdd(keyValuePair.Key, keyValuePair.Value); + } + } + + // Patch nullability + var customAttributes = context.JsonPropertyInfo.AttributeProvider?.GetCustomAttributes(false) ?? []; + var isNullable = !customAttributes.OfType().Any() + && GetOptionalValueIsNullable(context.JsonPropertyInfo.AttributeProvider as MemberInfo); + if (isNullable) + { + if (!isSchemaReference) + { + schema.Type |= JsonSchemaType.Null; + } + else + { + schema.Metadata ??= new Dictionary(); + schema.Metadata["x-is-nullable-property"] = true; + } + } + } + + private void TransformObjectSchema(OpenApiSchema schema, OpenApiSchemaTransformerContext context) + { + foreach (JsonPropertyInfo prop in context.JsonTypeInfo.Properties) + { + if (OptionalValue.IsOptionalValueType(prop.PropertyType)) + { + var propAttributes = prop.AttributeProvider?.GetCustomAttributes(false) ?? []; + + // If the property has a [Specified] attribute, mark it as required + if (propAttributes.Any(x => x.GetType()?.FullName == "OptionalValues.DataAnnotations.SpecifiedAttribute")) + { + schema.Required ??= new HashSet(StringComparer.Ordinal); + schema.Required.Add(prop.Name); + } + } + } + } + + private static bool GetOptionalValueIsNullable(ICustomAttributeProvider? memberInfo) + { + NullabilityInfo? nullabilityInfo = GetOptionalValueNullabilityInfo(memberInfo); + if (nullabilityInfo == null) + { + return false; + } + + return nullabilityInfo.ReadState == NullabilityState.Nullable; + } + + private static NullabilityInfo? GetNullabilityInfo(ICustomAttributeProvider memberInfo) + { + var nullabilityInfoContext = new NullabilityInfoContext(); + return memberInfo switch + { + PropertyInfo propertyInfo => nullabilityInfoContext.Create(propertyInfo), + FieldInfo fieldInfo => nullabilityInfoContext.Create(fieldInfo), + ParameterInfo parameterInfo => nullabilityInfoContext.Create(parameterInfo), + _ => null, + }; + } + + private static NullabilityInfo? GetOptionalValueNullabilityInfo(ICustomAttributeProvider? memberInfo) + { + if (memberInfo == null) + { + return null; + } + + NullabilityInfo? nullabilityInfo = GetNullabilityInfo(memberInfo); + if (nullabilityInfo == null + || !OptionalValue.IsOptionalValueType(nullabilityInfo.Type)) + { + return null; + } + + return nullabilityInfo.GenericTypeArguments[0]; + } +} \ No newline at end of file diff --git a/src/OptionalValues.OpenApi/PublicAPI.Shipped.txt b/src/OptionalValues.OpenApi/PublicAPI.Shipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/src/OptionalValues.OpenApi/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/OptionalValues.OpenApi/PublicAPI.Unshipped.txt b/src/OptionalValues.OpenApi/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..7f5b13a --- /dev/null +++ b/src/OptionalValues.OpenApi/PublicAPI.Unshipped.txt @@ -0,0 +1,2 @@ +OptionalValues.OpenApi.OpenApiOptionsExtensions +static OptionalValues.OpenApi.OpenApiOptionsExtensions.AddOptionalValueSupport(this Microsoft.AspNetCore.OpenApi.OpenApiOptions! options) -> void \ No newline at end of file diff --git a/src/OptionalValues.Swashbuckle/OptionalValues.Swashbuckle.csproj b/src/OptionalValues.Swashbuckle/OptionalValues.Swashbuckle.csproj index 327ef65..35919e5 100644 --- a/src/OptionalValues.Swashbuckle/OptionalValues.Swashbuckle.csproj +++ b/src/OptionalValues.Swashbuckle/OptionalValues.Swashbuckle.csproj @@ -1,7 +1,7 @@  - $(OptionalValuesLibraryTargetFrameworks) + $(OptionalValuesSwashbuckleTargetFrameworks) diff --git a/test/OptionalValues.OpenApi.TestApp/Endpoints/Primitives.cs b/test/OptionalValues.OpenApi.TestApp/Endpoints/Primitives.cs new file mode 100644 index 0000000..fd75abd --- /dev/null +++ b/test/OptionalValues.OpenApi.TestApp/Endpoints/Primitives.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Mvc; + +namespace OptionalValues.OpenApi.TestApp.Endpoints; + +public static class Primitives +{ + public static void MapPrimitives(this IEndpointRouteBuilder routes) + { + RouteGroupBuilder group = routes.MapGroup("/primitives"); + group.MapPost("body/baseline", (PrimitivesBodyBaseline body) => TypedResults.Ok()); + group.MapPost("body/optional", (PrimitivesBodyOptional body) => TypedResults.Ok()); + } + + public class PrimitivesBodyOptional + { + public OptionalValue Int { get; set; } + public OptionalValue NullableInt { get; set; } + public OptionalValue UInt { get; set; } + public OptionalValue NullableUInt { get; set; } + public OptionalValue Double { get; set; } + public OptionalValue NullableDouble { get; set; } + public OptionalValue Bool { get; set; } + public OptionalValue NullableBool { get; set; } + public OptionalValue String { get; set; } + public OptionalValue NullableString { get; set; } + public OptionalValue Guid { get; set; } + public OptionalValue NullableGuid { get; set; } + public OptionalValue DateTime { get; set; } + public OptionalValue NullableDateTime { get; set; } + public OptionalValue DateOnly { get; set; } + public OptionalValue NullableDateOnly { get; set; } + public OptionalValue DateTimeOffset { get; set; } + public OptionalValue NullableDateTimeOffset { get; set; } + public OptionalValue TimeOnly { get; set; } + public OptionalValue NullableTimeOnly { get; set; } + public OptionalValue TimeSpan { get; set; } + public OptionalValue NullableTimeSpan { get; set; } + } + + public class PrimitivesBodyBaseline + { + public int Int { get; set; } + public int? NullableInt { get; set; } + public uint UInt { get; set; } + public uint? NullableUInt { get; set; } + public double Double { get; set; } + public double? NullableDouble { get; set; } + public bool Bool { get; set; } + public bool? NullableBool { get; set; } + public string String { get; set; } = null!; + public string? NullableString { get; set; } + public Guid Guid { get; set; } + public Guid? NullableGuid { get; set; } + public DateTime DateTime { get; set; } + public DateTime? NullableDateTime { get; set; } + public DateOnly DateOnly { get; set; } + public DateOnly? NullableDateOnly { get; set; } + public DateTimeOffset DateTimeOffset { get; set; } + public DateTimeOffset? NullableDateTimeOffset { get; set; } + public TimeOnly TimeOnly { get; set; } + public TimeOnly? NullableTimeOnly { get; set; } + public TimeSpan TimeSpan { get; set; } + public TimeSpan? NullableTimeSpan { get; set; } + } +} \ No newline at end of file diff --git a/test/OptionalValues.OpenApi.TestApp/Endpoints/References.cs b/test/OptionalValues.OpenApi.TestApp/Endpoints/References.cs new file mode 100644 index 0000000..da80e95 --- /dev/null +++ b/test/OptionalValues.OpenApi.TestApp/Endpoints/References.cs @@ -0,0 +1,66 @@ +using Microsoft.AspNetCore.Mvc; +using OptionalValues; + +namespace OptionalValues.OpenApi.TestApp.Endpoints; + +public static class References +{ + public static void MapReferences(this IEndpointRouteBuilder routes) + { + RouteGroupBuilder group = routes.MapGroup("/references"); + group.MapPost("body/baseline", (ReferencesBodyBaseline body) => TypedResults.Ok()); + group.MapPost("body/optional", (ReferencesBodyOptional body) => TypedResults.Ok()); + } + + public class ReferencesBodyOptional + { + public OptionalValue A { get; set; } + } + + public class ReferencesBodyBaseline + { + public ReferenceA A { get; set; } = null!; + } + + // Baseline nested models (A -> B -> C; no circular reference currently, pending https://github.com/dotnet/aspnetcore/pull/64109) + public class ReferenceA + { + public string Name { get; set; } = null!; + public ReferenceB B { get; set; } = null!; + } + + public class ReferenceB + { + public string Description { get; set; } = null!; + public ReferenceC C { get; set; } = null!; + } + + public class ReferenceC + { + public int Value { get; set; } + + // NOT POSSIBLE TO HAVE CIRCULAR REFERENCES TILL https://github.com/dotnet/aspnetcore/pull/64109 is merged + //public ReferenceA? Parent { get; set; } // circular reference back to A + } + + // Optional versions of the nested models + public class ReferenceAOptional + { + public OptionalValue Name { get; set; } + public OptionalValue B { get; set; } + } + + public class ReferenceBOptional + { + public OptionalValue Description { get; set; } + public OptionalValue C { get; set; } + } + + public class ReferenceCOptional + { + public OptionalValue Value { get; set; } + + // NOT POSSIBLE TO HAVE CIRCULAR REFERENCES TILL https://github.com/dotnet/aspnetcore/pull/64109 is merged + //public OptionalValue Parent { get; set; } // circular back to A (nullable) + } +} \ No newline at end of file diff --git a/test/OptionalValues.OpenApi.TestApp/OptionalValues.OpenApi.TestApp.csproj b/test/OptionalValues.OpenApi.TestApp/OptionalValues.OpenApi.TestApp.csproj new file mode 100644 index 0000000..465a71d --- /dev/null +++ b/test/OptionalValues.OpenApi.TestApp/OptionalValues.OpenApi.TestApp.csproj @@ -0,0 +1,34 @@ + + + + net10.0 + enable + enable + + false + true + + true + $(NoWarn);CS1591 + + + + . + --file-name openapi + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/test/OptionalValues.OpenApi.TestApp/Program.cs b/test/OptionalValues.OpenApi.TestApp/Program.cs new file mode 100644 index 0000000..ac40451 --- /dev/null +++ b/test/OptionalValues.OpenApi.TestApp/Program.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +using OptionalValues; +using OptionalValues.OpenApi; +using OptionalValues.OpenApi.TestApp.Endpoints; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.Services.ConfigureHttpJsonOptions(options => +{ + options.SerializerOptions.AddOptionalValueSupport(); + options.SerializerOptions.NumberHandling = JsonNumberHandling.Strict; +}); +builder.Services.AddOpenApi(options => +{ + options.AddOptionalValueSupport(); +}); + +WebApplication app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.MapPrimitives(); +app.MapReferences(); + +app.Run(); + +public partial class Program +{ +} \ No newline at end of file diff --git a/test/OptionalValues.OpenApi.TestApp/Properties/launchSettings.json b/test/OptionalValues.OpenApi.TestApp/Properties/launchSettings.json new file mode 100644 index 0000000..a6c0942 --- /dev/null +++ b/test/OptionalValues.OpenApi.TestApp/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5128", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7090;http://localhost:5128", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/test/OptionalValues.OpenApi.TestApp/appsettings.json b/test/OptionalValues.OpenApi.TestApp/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/test/OptionalValues.OpenApi.TestApp/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/test/OptionalValues.OpenApi.TestApp/openapi.json b/test/OptionalValues.OpenApi.TestApp/openapi.json new file mode 100644 index 0000000..1cb1285 --- /dev/null +++ b/test/OptionalValues.OpenApi.TestApp/openapi.json @@ -0,0 +1,428 @@ +{ + "openapi": "3.1.1", + "info": { + "title": "OptionalValues.OpenApi.TestApp | v1", + "version": "1.0.0" + }, + "paths": { + "/primitives/body/baseline": { + "post": { + "tags": [ + "OptionalValues.OpenApi.TestApp" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PrimitivesBodyBaseline" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/primitives/body/optional": { + "post": { + "tags": [ + "OptionalValues.OpenApi.TestApp" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PrimitivesBodyOptional" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/references/body/baseline": { + "post": { + "tags": [ + "OptionalValues.OpenApi.TestApp" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReferencesBodyBaseline" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/references/body/optional": { + "post": { + "tags": [ + "OptionalValues.OpenApi.TestApp" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReferencesBodyOptional" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + } + } + }, + "components": { + "schemas": { + "PrimitivesBodyBaseline": { + "type": "object", + "properties": { + "int": { + "type": "integer", + "format": "int32" + }, + "nullableInt": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "uInt": { + "type": "integer", + "format": "uint32" + }, + "nullableUInt": { + "type": [ + "null", + "integer" + ], + "format": "uint32" + }, + "double": { + "type": "number", + "format": "double" + }, + "nullableDouble": { + "type": [ + "null", + "number" + ], + "format": "double" + }, + "bool": { + "type": "boolean" + }, + "nullableBool": { + "type": [ + "null", + "boolean" + ] + }, + "string": { + "type": "string" + }, + "nullableString": { + "type": [ + "null", + "string" + ] + }, + "guid": { + "type": "string", + "format": "uuid" + }, + "nullableGuid": { + "type": [ + "null", + "string" + ], + "format": "uuid" + }, + "dateTime": { + "type": "string", + "format": "date-time" + }, + "nullableDateTime": { + "type": [ + "null", + "string" + ], + "format": "date-time" + }, + "dateOnly": { + "type": "string", + "format": "date" + }, + "nullableDateOnly": { + "type": [ + "null", + "string" + ], + "format": "date" + }, + "dateTimeOffset": { + "type": "string", + "format": "date-time" + }, + "nullableDateTimeOffset": { + "type": [ + "null", + "string" + ], + "format": "date-time" + }, + "timeOnly": { + "type": "string", + "format": "time" + }, + "nullableTimeOnly": { + "type": [ + "null", + "string" + ], + "format": "time" + }, + "timeSpan": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": "string" + }, + "nullableTimeSpan": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": [ + "null", + "string" + ] + } + } + }, + "PrimitivesBodyOptional": { + "type": "object", + "properties": { + "int": { + "type": "integer", + "format": "int32" + }, + "nullableInt": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "uInt": { + "type": "integer", + "format": "uint32" + }, + "nullableUInt": { + "type": [ + "null", + "integer" + ], + "format": "uint32" + }, + "double": { + "type": "number", + "format": "double" + }, + "nullableDouble": { + "type": [ + "null", + "number" + ], + "format": "double" + }, + "bool": { + "type": "boolean" + }, + "nullableBool": { + "type": [ + "null", + "boolean" + ] + }, + "string": { + "type": "string" + }, + "nullableString": { + "type": [ + "null", + "string" + ] + }, + "guid": { + "type": "string", + "format": "uuid" + }, + "nullableGuid": { + "type": [ + "null", + "string" + ], + "format": "uuid" + }, + "dateTime": { + "type": "string", + "format": "date-time" + }, + "nullableDateTime": { + "type": [ + "null", + "string" + ], + "format": "date-time" + }, + "dateOnly": { + "type": "string", + "format": "date" + }, + "nullableDateOnly": { + "type": [ + "null", + "string" + ], + "format": "date" + }, + "dateTimeOffset": { + "type": "string", + "format": "date-time" + }, + "nullableDateTimeOffset": { + "type": [ + "null", + "string" + ], + "format": "date-time" + }, + "timeOnly": { + "type": "string", + "format": "time" + }, + "nullableTimeOnly": { + "type": [ + "null", + "string" + ], + "format": "time" + }, + "timeSpan": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": "string" + }, + "nullableTimeSpan": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": [ + "null", + "string" + ] + } + } + }, + "ReferenceA": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "b": { + "$ref": "#/components/schemas/ReferenceB" + } + } + }, + "ReferenceAOptional": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "b": { + "$ref": "#/components/schemas/ReferenceBOptional" + } + } + }, + "ReferenceB": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "c": { + "$ref": "#/components/schemas/ReferenceC" + } + } + }, + "ReferenceBOptional": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "c": { + "$ref": "#/components/schemas/ReferenceCOptional" + } + } + }, + "ReferenceC": { + "type": "object", + "properties": { + "value": { + "type": "integer", + "format": "int32" + } + } + }, + "ReferenceCOptional": { + "type": "object", + "properties": { + "value": { + "type": "integer", + "format": "int32" + } + } + }, + "ReferencesBodyBaseline": { + "type": "object", + "properties": { + "a": { + "$ref": "#/components/schemas/ReferenceA" + } + } + }, + "ReferencesBodyOptional": { + "type": "object", + "properties": { + "a": { + "$ref": "#/components/schemas/ReferenceAOptional" + } + } + } + } + }, + "tags": [ + { + "name": "OptionalValues.OpenApi.TestApp" + } + ] +} \ No newline at end of file diff --git a/test/OptionalValues.OpenApi.Tests/OpenApiDocumentSchemaExtensions.cs b/test/OptionalValues.OpenApi.Tests/OpenApiDocumentSchemaExtensions.cs new file mode 100644 index 0000000..266872e --- /dev/null +++ b/test/OptionalValues.OpenApi.Tests/OpenApiDocumentSchemaExtensions.cs @@ -0,0 +1,40 @@ +using Microsoft.OpenApi; + +namespace OptionalValues.OpenApi.Tests; + +public static class OpenApiDocumentSchemaExtensions +{ + public static (OpenApiSchema baseline, OpenApiSchema optional) GetComparisonOperationRequestBodySchemasByPath(this OpenApiDocument document, string pathBase) + { + IOpenApiSchema? baselineRequestBodySchema = document + .Paths[$"{pathBase}/baseline"] + .Operations![HttpMethod.Post] + .RequestBody! + .Content!["application/json"] + .Schema; + + IOpenApiSchema? optionalRequestBodySchema = document + .Paths[$"{pathBase}/optional"] + .Operations![HttpMethod.Post] + .RequestBody! + .Content!["application/json"] + .Schema; + + return (baselineRequestBodySchema!.Unwrap(), optionalRequestBodySchema!.Unwrap()); + } + + private static OpenApiSchema Unwrap(this IOpenApiSchema schema) + { + if (schema is OpenApiSchema actualSchema) + { + return actualSchema; + } + + if (schema is OpenApiSchemaReference reference) + { + return reference.RecursiveTarget ?? throw new InvalidOperationException("Schema reference target is null."); + } + + throw new InvalidOperationException("Schema is neither a schema nor a reference."); + } +} \ No newline at end of file diff --git a/test/OptionalValues.OpenApi.Tests/OpenApiDocumentTestBase.cs b/test/OptionalValues.OpenApi.Tests/OpenApiDocumentTestBase.cs new file mode 100644 index 0000000..726058f --- /dev/null +++ b/test/OptionalValues.OpenApi.Tests/OpenApiDocumentTestBase.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace OptionalValues.OpenApi.Tests; + +public class OpenApiDocumentTestBase +{ + protected static async Task GetDocument() + { + await using var application = new WebApplicationFactory(); + IOpenApiDocumentProvider? documentProvider = application.Services.GetKeyedService("v1"); + + OpenApiDocument document = await documentProvider!.GetOpenApiDocumentAsync(); + return document; + } +} \ No newline at end of file diff --git a/test/OptionalValues.OpenApi.Tests/OptionalValues.OpenApi.Tests.csproj b/test/OptionalValues.OpenApi.Tests/OptionalValues.OpenApi.Tests.csproj new file mode 100644 index 0000000..bf0d5d3 --- /dev/null +++ b/test/OptionalValues.OpenApi.Tests/OptionalValues.OpenApi.Tests.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + enable + enable + + false + true + + + + $(InterceptorsNamespaces);Microsoft.AspNetCore.OpenApi.Generated + + + + + + + + + + + + + + + + + + + + diff --git a/test/OptionalValues.OpenApi.Tests/PrimitivesTest.cs b/test/OptionalValues.OpenApi.Tests/PrimitivesTest.cs new file mode 100644 index 0000000..147f250 --- /dev/null +++ b/test/OptionalValues.OpenApi.Tests/PrimitivesTest.cs @@ -0,0 +1,14 @@ +using Microsoft.OpenApi; + +namespace OptionalValues.OpenApi.Tests; + +public class PrimitivesTest : OpenApiDocumentTestBase +{ + [Fact] + public async Task Body() + { + OpenApiDocument document = await GetDocument(); + (OpenApiSchema baselineSchema, OpenApiSchema optionalSchema) = document.GetComparisonOperationRequestBodySchemasByPath("/primitives/body"); + await optionalSchema.ShouldBeEqualToBaselineSchema(baselineSchema); + } +} \ No newline at end of file diff --git a/test/OptionalValues.OpenApi.Tests/ReferencesTest.cs b/test/OptionalValues.OpenApi.Tests/ReferencesTest.cs new file mode 100644 index 0000000..72319af --- /dev/null +++ b/test/OptionalValues.OpenApi.Tests/ReferencesTest.cs @@ -0,0 +1,15 @@ +using Microsoft.OpenApi; + +namespace OptionalValues.OpenApi.Tests; + +public class ReferencesTest : OpenApiDocumentTestBase +{ + [Fact] + public async Task Body() + { + OpenApiDocument document = await GetDocument(); + (OpenApiSchema baselineSchema, OpenApiSchema optionalSchema) = document.GetComparisonOperationRequestBodySchemasByPath("/references/body"); + await optionalSchema.ShouldBeEqualToBaselineSchema(baselineSchema, "Optional", "Baseline"); + } +} + diff --git a/test/OptionalValues.OpenApi.Tests/SchemaAssertionExtensions.cs b/test/OptionalValues.OpenApi.Tests/SchemaAssertionExtensions.cs new file mode 100644 index 0000000..6faa62c --- /dev/null +++ b/test/OptionalValues.OpenApi.Tests/SchemaAssertionExtensions.cs @@ -0,0 +1,22 @@ +using Microsoft.OpenApi; + +using Shouldly; + +namespace OptionalValues.OpenApi.Tests; + +public static class SchemaAssertionExtensions +{ + public static async Task ShouldBeEqualToBaselineSchema(this OpenApiSchema actualSchema, OpenApiSchema baselineSchema, params string[] ignoreStrings) + { + var actualSchemaJson = await actualSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + var baselineSchemaJson = await baselineSchema.SerializeAsJsonAsync(OpenApiSpecVersion.OpenApi3_1); + + foreach (var ignoreString in ignoreStrings) + { + actualSchemaJson = actualSchemaJson.Replace(ignoreString, ""); + baselineSchemaJson = baselineSchemaJson.Replace(ignoreString, ""); + } + + actualSchemaJson.ShouldBe(baselineSchemaJson); + } +} \ No newline at end of file diff --git a/test/OptionalValues.Swashbuckle.Tests/OptionalValues.Swashbuckle.Tests.csproj b/test/OptionalValues.Swashbuckle.Tests/OptionalValues.Swashbuckle.Tests.csproj index 78ed1d5..9eec8aa 100644 --- a/test/OptionalValues.Swashbuckle.Tests/OptionalValues.Swashbuckle.Tests.csproj +++ b/test/OptionalValues.Swashbuckle.Tests/OptionalValues.Swashbuckle.Tests.csproj @@ -1,7 +1,7 @@ - $(OptionalValuesTestsTargetFrameworks) + $(OptionalValuesSwashbuckleTargetFrameworks) enable enable diff --git a/version.json b/version.json index 7cf6f37..fe3ec17 100644 --- a/version.json +++ b/version.json @@ -1,13 +1,20 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "0.7", + "version": "0.8-preview", "publicReleaseRefSpec": [ "^refs/heads/main$", - "^refs/heads/v\\d+(?:\\.\\d+)?$" + "^refs/heads/v\\d+(?:\\.\\d+)?$", + "^refs/heads/release/v\\d+(?:\\.\\d+)?$" ], "cloudBuild": { "buildNumber": { "enabled": true } + }, + "release": { + "tagName": "v{version}", + "branchName": "release/v{version}", + "versionIncrement": "minor", + "firstUnstableTag": "preview" } }