diff --git a/src/OpenApi/src/OpenApiGenerator.cs b/src/OpenApi/src/OpenApiGenerator.cs index e8f2f59d5b11..0cfcc876e6aa 100644 --- a/src/OpenApi/src/OpenApiGenerator.cs +++ b/src/OpenApi/src/OpenApiGenerator.cs @@ -200,7 +200,7 @@ private static OpenApiResponses GetOpenApiResponses(MethodInfo method, EndpointM { responseContent[contentType] = new OpenApiMediaType { - Schema = new OpenApiSchema { Type = SchemaGenerator.GetOpenApiSchemaType(type) } + Schema = OpenApiSchemaGenerator.GetOpenApiSchema(type) }; } @@ -271,10 +271,7 @@ private static void GenerateDefaultResponses(Dictionary GetOpenApiParameters(MethodInfo methodInfo, Endpo Name = parameter.Name, In = parameterLocation, Content = GetOpenApiParameterContent(metadata), - Schema = new OpenApiSchema { Type = SchemaGenerator.GetOpenApiSchemaType(parameter.ParameterType) }, + Schema = OpenApiSchemaGenerator.GetOpenApiSchema(parameter.ParameterType), Required = !isOptional }; diff --git a/src/OpenApi/src/OpenApiSchemaGenerator.cs b/src/OpenApi/src/OpenApiSchemaGenerator.cs new file mode 100644 index 000000000000..5bdbb85b3795 --- /dev/null +++ b/src/OpenApi/src/OpenApiSchemaGenerator.cs @@ -0,0 +1,77 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections; +using Microsoft.AspNetCore.Http; +using Microsoft.OpenApi.Models; + +namespace Microsoft.AspNetCore.OpenApi; + +internal static class OpenApiSchemaGenerator +{ + private static readonly Dictionary simpleTypesAndFormats = + new() + { + [typeof(bool)] = ("boolean", null), + [typeof(byte)] = ("string", "byte"), + [typeof(int)] = ("integer", "int32"), + [typeof(uint)] = ("integer", "int32"), + [typeof(ushort)] = ("integer", "int32"), + [typeof(long)] = ("integer", "int64"), + [typeof(ulong)] = ("integer", "int64"), + [typeof(float)] = ("number", "float"), + [typeof(double)] = ("number", "double"), + [typeof(decimal)] = ("number", "double"), + [typeof(DateTime)] = ("string", "date-time"), + [typeof(DateTimeOffset)] = ("string", "date-time"), + [typeof(TimeSpan)] = ("string", "date-span"), + [typeof(Guid)] = ("string", "uuid"), + [typeof(char)] = ("string", null), + [typeof(Uri)] = ("string", "uri"), + [typeof(string)] = ("string", null), + [typeof(object)] = ("object", null) + }; + + internal static OpenApiSchema GetOpenApiSchema(Type? type) + { + if (type is null) + { + return new OpenApiSchema(); + } + + var (openApiType, openApiFormat) = GetTypeAndFormatProperties(type); + return new OpenApiSchema + { + Type = openApiType, + Format = openApiFormat, + Nullable = Nullable.GetUnderlyingType(type) != null, + }; + } + + private static (string, string?) GetTypeAndFormatProperties(Type type) + { + type = Nullable.GetUnderlyingType(type) ?? type; + + if (simpleTypesAndFormats.TryGetValue(type, out var typeAndFormat)) + { + return typeAndFormat; + } + + if (type == typeof(IFormFileCollection) || type == typeof(IFormFile)) + { + return ("object", null); + } + + if (typeof(IDictionary).IsAssignableFrom(type)) + { + return ("object", null); + } + + if (type != typeof(string) && (type.IsArray || typeof(IEnumerable).IsAssignableFrom(type))) + { + return ("array", null); + } + + return ("object", null); + } +} diff --git a/src/OpenApi/src/SchemaGenerator.cs b/src/OpenApi/src/SchemaGenerator.cs deleted file mode 100644 index 3eeb16fe4e79..000000000000 --- a/src/OpenApi/src/SchemaGenerator.cs +++ /dev/null @@ -1,44 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.AspNetCore.OpenApi; - -internal static class SchemaGenerator -{ - internal static string GetOpenApiSchemaType(Type? inputType) - { - if (inputType == null) - { - throw new ArgumentNullException(nameof(inputType)); - } - - var type = Nullable.GetUnderlyingType(inputType) ?? inputType; - - if (typeof(string).IsAssignableFrom(type) || typeof(DateTime).IsAssignableTo(type)) - { - return "string"; - } - else if (typeof(bool).IsAssignableFrom(type)) - { - return "boolean"; - } - else if (typeof(int).IsAssignableFrom(type) - || typeof(double).IsAssignableFrom(type) - || typeof(float).IsAssignableFrom(type)) - { - return "number"; - } - else if (typeof(long).IsAssignableFrom(type)) - { - return "integer"; - } - else if (type.IsArray) - { - return "array"; - } - else - { - return "object"; - } - } -} diff --git a/src/OpenApi/test/OpenApiGeneratorTests.cs b/src/OpenApi/test/OpenApiGeneratorTests.cs index 459966c62f43..54ed1707dcce 100644 --- a/src/OpenApi/test/OpenApiGeneratorTests.cs +++ b/src/OpenApi/test/OpenApiGeneratorTests.cs @@ -170,7 +170,7 @@ public void AddsMultipleResponseFormatsFromMetadataWithPoco() var content = Assert.Single(createdResponseType.Content); Assert.NotNull(createdResponseType); - Assert.Equal("object", content.Value.Schema.Type); + Assert.Equal("string", content.Value.Schema.Type); Assert.Equal("application/json", createdResponseType.Content.Keys.First()); var badRequestResponseType = responses["400"]; @@ -209,7 +209,7 @@ public void AddsFromRouteParameterAsPath() static void AssertPathParameter(OpenApiOperation operation) { var param = Assert.Single(operation.Parameters); - Assert.Equal("number", param.Schema.Type); + Assert.Equal("integer", param.Schema.Type); Assert.Equal(ParameterLocation.Path, param.In); } @@ -235,7 +235,7 @@ public void AddsFromRouteParameterAsPathWithNullablePrimitiveType() static void AssertPathParameter(OpenApiOperation operation) { var param = Assert.Single(operation.Parameters); - Assert.Equal("number", param.Schema.Type); + Assert.Equal("integer", param.Schema.Type); Assert.Equal(ParameterLocation.Path, param.In); } @@ -265,12 +265,12 @@ static void AssertQueryParameter(OpenApiOperation operation, string type) Assert.Equal(ParameterLocation.Query, param.In); } - AssertQueryParameter(GetOpenApiOperation((int foo) => { }, "/"), "number"); - AssertQueryParameter(GetOpenApiOperation(([FromQuery] int foo) => { }), "number"); + AssertQueryParameter(GetOpenApiOperation((int foo) => { }, "/"), "integer"); + AssertQueryParameter(GetOpenApiOperation(([FromQuery] int foo) => { }), "integer"); AssertQueryParameter(GetOpenApiOperation(([FromQuery] TryParseStringRecordStruct foo) => { }), "object"); AssertQueryParameter(GetOpenApiOperation((int[] foo) => { }, "/"), "array"); AssertQueryParameter(GetOpenApiOperation((string[] foo) => { }, "/"), "array"); - AssertQueryParameter(GetOpenApiOperation((StringValues foo) => { }, "/"), "object"); + AssertQueryParameter(GetOpenApiOperation((StringValues foo) => { }, "/"), "array"); AssertQueryParameter(GetOpenApiOperation((TryParseStringRecordStruct[] foo) => { }, "/"), "array"); } @@ -297,7 +297,7 @@ public void AddsFromHeaderParameterAsHeader() var operation = GetOpenApiOperation(([FromHeader] int foo) => { }); var param = Assert.Single(operation.Parameters); - Assert.Equal("number", param.Schema.Type); + Assert.Equal("integer", param.Schema.Type); Assert.Equal(ParameterLocation.Header, param.In); } @@ -325,7 +325,7 @@ static void AssertBodyParameter(OpenApiOperation operation, string expectedName, } AssertBodyParameter(GetOpenApiOperation((InferredJsonClass foo) => { }), "foo", "object"); - AssertBodyParameter(GetOpenApiOperation(([FromBody] int bar) => { }), "bar", "number"); + AssertBodyParameter(GetOpenApiOperation(([FromBody] int bar) => { }), "bar", "integer"); } #nullable enable @@ -338,13 +338,13 @@ public void AddsMultipleParameters() var fooParam = operation.Parameters[0]; Assert.Equal("foo", fooParam.Name); - Assert.Equal("number", fooParam.Schema.Type); + Assert.Equal("integer", fooParam.Schema.Type); Assert.Equal(ParameterLocation.Path, fooParam.In); Assert.True(fooParam.Required); var barParam = operation.Parameters[1]; Assert.Equal("bar", barParam.Name); - Assert.Equal("number", barParam.Schema.Type); + Assert.Equal("integer", barParam.Schema.Type); Assert.Equal(ParameterLocation.Query, barParam.In); Assert.True(barParam.Required); @@ -363,13 +363,14 @@ public void TestParameterIsRequired() var fooParam = operation.Parameters[0]; Assert.Equal("foo", fooParam.Name); - Assert.Equal("number", fooParam.Schema.Type); + Assert.Equal("integer", fooParam.Schema.Type); Assert.Equal(ParameterLocation.Path, fooParam.In); Assert.True(fooParam.Required); var barParam = operation.Parameters[1]; Assert.Equal("bar", barParam.Name); - Assert.Equal("number", barParam.Schema.Type); + Assert.Equal("integer", barParam.Schema.Type); + Assert.True(barParam.Schema.Nullable); Assert.Equal(ParameterLocation.Query, barParam.In); Assert.False(barParam.Required); } @@ -388,7 +389,7 @@ public void TestParameterIsRequiredForObliviousNullabilityContext() Assert.False(fooParam.Required); var barParam = operation.Parameters[1]; - Assert.Equal("number", barParam.Schema.Type); + Assert.Equal("integer", barParam.Schema.Type); Assert.Equal(ParameterLocation.Query, barParam.In); Assert.True(barParam.Required); } diff --git a/src/OpenApi/test/OpenApiSchemaGeneratorTests.cs b/src/OpenApi/test/OpenApiSchemaGeneratorTests.cs new file mode 100644 index 000000000000..47816c917123 --- /dev/null +++ b/src/OpenApi/test/OpenApiSchemaGeneratorTests.cs @@ -0,0 +1,111 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.OpenApi; + +namespace Microsoft.AspNetCore.OpenApi.Tests; + +public class OpenApiSchemaGeneratorTests +{ + [Theory] + [InlineData(typeof(Dictionary))] + [InlineData(typeof(Todo))] + public void CanGenerateCorrectSchemaForDictionaryTypes(Type type) + { + var schema = OpenApiSchemaGenerator.GetOpenApiSchema(type); + Assert.NotNull(schema); + Assert.Equal("object", schema.Type); + } + + [Theory] + [InlineData(typeof(IList))] + [InlineData(typeof(Products))] + public void CanGenerateSchemaForListTypes(Type type) + { + var schema = OpenApiSchemaGenerator.GetOpenApiSchema(type); + Assert.NotNull(schema); + Assert.Equal("array", schema.Type); + } + + [Theory] + [InlineData(typeof(DateTime))] + [InlineData(typeof(DateTimeOffset))] + public void CanGenerateSchemaForDateTimeTypes(Type type) + { + var schema = OpenApiSchemaGenerator.GetOpenApiSchema(type); + Assert.NotNull(schema); + Assert.Equal("string", schema.Type); + Assert.Equal("date-time", schema.Format); + } + + [Fact] + public void CanGenerateSchemaForDateSpanTypes() + { + var schema = OpenApiSchemaGenerator.GetOpenApiSchema(typeof(TimeSpan)); + Assert.NotNull(schema); + Assert.Equal("string", schema.Type); + Assert.Equal("date-span", schema.Format); + } + + class Todo : Dictionary { } + class Products : IList + { + public int this[int index] { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + + public int Count => throw new NotImplementedException(); + + public bool IsReadOnly => throw new NotImplementedException(); + + public void Add(int item) + { + throw new NotImplementedException(); + } + + public void Clear() + { + throw new NotImplementedException(); + } + + public bool Contains(int item) + { + throw new NotImplementedException(); + } + + public void CopyTo(int[] array, int arrayIndex) + { + throw new NotImplementedException(); + } + + public IEnumerator GetEnumerator() + { + throw new NotImplementedException(); + } + + public int IndexOf(int item) + { + throw new NotImplementedException(); + } + + public void Insert(int index, int item) + { + throw new NotImplementedException(); + } + + public bool Remove(int item) + { + throw new NotImplementedException(); + } + + public void RemoveAt(int index) + { + throw new NotImplementedException(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + throw new NotImplementedException(); + } + } +}