diff --git a/JsonApiDotNetCore.sln.DotSettings b/JsonApiDotNetCore.sln.DotSettings index bd9975440c..9902bb57d9 100644 --- a/JsonApiDotNetCore.sln.DotSettings +++ b/JsonApiDotNetCore.sln.DotSettings @@ -15,6 +15,8 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$, $NAME$); 50 False True + True + swagger.g.json swagger.json SOLUTION diff --git a/src/JsonApiDotNetCore.OpenApi/MemberInfoExtensions.cs b/src/JsonApiDotNetCore.OpenApi/MemberInfoExtensions.cs deleted file mode 100644 index a6f46ccb9d..0000000000 --- a/src/JsonApiDotNetCore.OpenApi/MemberInfoExtensions.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Reflection; -using Swashbuckle.AspNetCore.SwaggerGen; - -namespace JsonApiDotNetCore.OpenApi; - -internal static class MemberInfoExtensions -{ - public static TypeCategory GetTypeCategory(this MemberInfo source) - { - ArgumentGuard.NotNull(source, nameof(source)); - - Type memberType; - - if (source.MemberType.HasFlag(MemberTypes.Field)) - { - memberType = ((FieldInfo)source).FieldType; - } - else if (source.MemberType.HasFlag(MemberTypes.Property)) - { - memberType = ((PropertyInfo)source).PropertyType; - } - else - { - throw new NotSupportedException($"Member type '{source.MemberType}' must be a property or field."); - } - - if (memberType.IsValueType) - { - return Nullable.GetUnderlyingType(memberType) != null ? TypeCategory.NullableValueType : TypeCategory.ValueType; - } - - // Once we switch to .NET 6, we should rely instead on the built-in reflection APIs for nullability information. - // See https://devblogs.microsoft.com/dotnet/announcing-net-6-preview-7/#libraries-reflection-apis-for-nullability-information. - return source.IsNonNullableReferenceType() ? TypeCategory.NonNullableReferenceType : TypeCategory.NullableReferenceType; - } -} diff --git a/src/JsonApiDotNetCore.OpenApi/ResourceFieldAttributeExtensions.cs b/src/JsonApiDotNetCore.OpenApi/ResourceFieldAttributeExtensions.cs index 32bf9f5187..d4b9c40fea 100644 --- a/src/JsonApiDotNetCore.OpenApi/ResourceFieldAttributeExtensions.cs +++ b/src/JsonApiDotNetCore.OpenApi/ResourceFieldAttributeExtensions.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.Reflection; using JsonApiDotNetCore.Resources.Annotations; using Swashbuckle.AspNetCore.SwaggerGen; @@ -6,16 +7,34 @@ namespace JsonApiDotNetCore.OpenApi; internal static class ResourceFieldAttributeExtensions { + private static readonly NullabilityInfoContext NullabilityInfoContext = new(); + public static bool IsNullable(this ResourceFieldAttribute source) { - TypeCategory fieldTypeCategory = source.Property.GetTypeCategory(); bool hasRequiredAttribute = source.Property.HasAttribute(); - return fieldTypeCategory switch + if (hasRequiredAttribute) { - TypeCategory.NonNullableReferenceType or TypeCategory.ValueType => false, - TypeCategory.NullableReferenceType or TypeCategory.NullableValueType => !hasRequiredAttribute, - _ => throw new UnreachableCodeException() - }; + // Reflects the following cases, independent of NRT setting + // `[Required] int? Number` => not nullable + // `[Required] int Number` => not nullable + // `[Required] string Text` => not nullable + // `[Required] string? Text` => not nullable + // `[Required] string Text` => not nullable + return false; + } + + NullabilityInfo nullabilityInfo = NullabilityInfoContext.Create(source.Property); + + // Reflects the following cases: + // Independent of NRT: + // `int? Number` => nullable + // `int Number` => not nullable + // If NRT is enabled: + // `string? Text` => nullable + // `string Text` => not nullable + // If NRT is disabled: + // `string Text` => nullable + return nullabilityInfo.ReadState is not NullabilityState.NotNull; } } diff --git a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/JsonApiSchemaGenerator.cs b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/JsonApiSchemaGenerator.cs index a0e7159a89..c5fe2b6a2d 100644 --- a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/JsonApiSchemaGenerator.cs +++ b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/JsonApiSchemaGenerator.cs @@ -26,6 +26,13 @@ internal sealed class JsonApiSchemaGenerator : ISchemaGenerator typeof(NullableToOneRelationshipInRequest<>) }; + private static readonly Type[] JsonApiDocumentWithNullableDataOpenTypes = + { + typeof(NullableSecondaryResourceResponseDocument<>), + typeof(NullableResourceIdentifierResponseDocument<>), + typeof(NullableToOneRelationshipInRequest<>) + }; + private readonly ISchemaGenerator _defaultSchemaGenerator; private readonly ResourceObjectSchemaGenerator _resourceObjectSchemaGenerator; private readonly NullableReferenceSchemaGenerator _nullableReferenceSchemaGenerator; @@ -58,7 +65,7 @@ public OpenApiSchema GenerateSchema(Type type, SchemaRepository schemaRepository { OpenApiSchema schema = GenerateJsonApiDocumentSchema(type); - if (IsDataPropertyNullable(type)) + if (IsDataPropertyNullableInDocument(type)) { SetDataObjectSchemaToNullable(schema); } @@ -98,18 +105,11 @@ private static bool IsManyDataDocument(Type documentType) return documentType.BaseType!.GetGenericTypeDefinition() == typeof(ManyData<>); } - private static bool IsDataPropertyNullable(Type type) + private static bool IsDataPropertyNullableInDocument(Type documentType) { - PropertyInfo? dataProperty = type.GetProperty(nameof(JsonApiObjectPropertyName.Data)); - - if (dataProperty == null) - { - throw new UnreachableCodeException(); - } - - TypeCategory typeCategory = dataProperty.GetTypeCategory(); + Type documentOpenType = documentType.GetGenericTypeDefinition(); - return typeCategory == TypeCategory.NullableReferenceType; + return JsonApiDocumentWithNullableDataOpenTypes.Contains(documentOpenType); } private void SetDataObjectSchemaToNullable(OpenApiSchema referenceSchemaForDocument) diff --git a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceFieldObjectSchemaBuilder.cs b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceFieldObjectSchemaBuilder.cs index ef8c705424..6dbf35dd1e 100644 --- a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceFieldObjectSchemaBuilder.cs +++ b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceFieldObjectSchemaBuilder.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.Reflection; -using System.Text.Json; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.OpenApi.JsonApiObjects; using JsonApiDotNetCore.OpenApi.JsonApiObjects.Relationships; using JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects; @@ -12,35 +12,46 @@ namespace JsonApiDotNetCore.OpenApi.SwaggerComponents; internal sealed class ResourceFieldObjectSchemaBuilder { - private static readonly Type[] RelationshipInResponseOpenTypes = + private static readonly NullabilityInfoContext NullabilityInfoContext = new(); + + private static readonly Type[] RelationshipSchemaInResponseOpenTypes = { typeof(ToOneRelationshipInResponse<>), typeof(ToManyRelationshipInResponse<>), typeof(NullableToOneRelationshipInResponse<>) }; + private static readonly Type[] NullableRelationshipSchemaOpenTypes = + { + typeof(NullableToOneRelationshipInRequest<>), + typeof(NullableToOneRelationshipInResponse<>) + }; + private readonly ResourceTypeInfo _resourceTypeInfo; private readonly ISchemaRepositoryAccessor _schemaRepositoryAccessor; private readonly SchemaGenerator _defaultSchemaGenerator; private readonly ResourceTypeSchemaGenerator _resourceTypeSchemaGenerator; + private readonly IJsonApiOptions _options; private readonly SchemaRepository _resourceSchemaRepository = new(); private readonly NullableReferenceSchemaGenerator _nullableReferenceSchemaGenerator; private readonly IDictionary _schemasForResourceFields; public ResourceFieldObjectSchemaBuilder(ResourceTypeInfo resourceTypeInfo, ISchemaRepositoryAccessor schemaRepositoryAccessor, - SchemaGenerator defaultSchemaGenerator, ResourceTypeSchemaGenerator resourceTypeSchemaGenerator, JsonNamingPolicy? namingPolicy) + SchemaGenerator defaultSchemaGenerator, ResourceTypeSchemaGenerator resourceTypeSchemaGenerator, IJsonApiOptions options) { ArgumentGuard.NotNull(resourceTypeInfo, nameof(resourceTypeInfo)); ArgumentGuard.NotNull(schemaRepositoryAccessor, nameof(schemaRepositoryAccessor)); ArgumentGuard.NotNull(defaultSchemaGenerator, nameof(defaultSchemaGenerator)); ArgumentGuard.NotNull(resourceTypeSchemaGenerator, nameof(resourceTypeSchemaGenerator)); + ArgumentGuard.NotNull(options, nameof(options)); _resourceTypeInfo = resourceTypeInfo; _schemaRepositoryAccessor = schemaRepositoryAccessor; _defaultSchemaGenerator = defaultSchemaGenerator; _resourceTypeSchemaGenerator = resourceTypeSchemaGenerator; + _options = options; - _nullableReferenceSchemaGenerator = new NullableReferenceSchemaGenerator(schemaRepositoryAccessor, namingPolicy); + _nullableReferenceSchemaGenerator = new NullableReferenceSchemaGenerator(schemaRepositoryAccessor, options.SerializerOptions.PropertyNamingPolicy); _schemasForResourceFields = GetFieldSchemas(); } @@ -108,15 +119,14 @@ private bool IsFieldRequired(ResourceFieldAttribute field) return false; } - TypeCategory fieldTypeCategory = field.Property.GetTypeCategory(); bool hasRequiredAttribute = field.Property.HasAttribute(); - return fieldTypeCategory switch + NullabilityInfo nullabilityInfo = NullabilityInfoContext.Create(field.Property); + + return field.Property.PropertyType.IsValueType switch { - TypeCategory.NonNullableReferenceType => true, - TypeCategory.ValueType => hasRequiredAttribute, - TypeCategory.NullableReferenceType or TypeCategory.NullableValueType => hasRequiredAttribute, - _ => throw new UnreachableCodeException() + true => hasRequiredAttribute, + false => _options.ValidateModelState ? nullabilityInfo.ReadState == NullabilityState.NotNull || hasRequiredAttribute : hasRequiredAttribute }; } @@ -194,7 +204,9 @@ private OpenApiSchema CreateRelationshipSchema(Type relationshipSchemaType) OpenApiSchema fullSchema = _schemaRepositoryAccessor.Current.Schemas[referenceSchema.Reference.Id]; - if (IsDataPropertyNullable(relationshipSchemaType)) + Console.WriteLine(relationshipSchemaType.FullName); + + if (IsDataPropertyNullableInRelationshipSchemaType(relationshipSchemaType)) { fullSchema.Properties[JsonApiObjectPropertyName.Data] = _nullableReferenceSchemaGenerator.GenerateSchema(fullSchema.Properties[JsonApiObjectPropertyName.Data]); @@ -212,18 +224,12 @@ private static bool IsRelationshipInResponseType(Type relationshipSchemaType) { Type relationshipSchemaOpenType = relationshipSchemaType.GetGenericTypeDefinition(); - return RelationshipInResponseOpenTypes.Contains(relationshipSchemaOpenType); + return RelationshipSchemaInResponseOpenTypes.Contains(relationshipSchemaOpenType); } - private static bool IsDataPropertyNullable(Type type) + private static bool IsDataPropertyNullableInRelationshipSchemaType(Type relationshipSchemaType) { - PropertyInfo? dataProperty = type.GetProperty(nameof(JsonApiObjectPropertyName.Data)); - - if (dataProperty == null) - { - throw new UnreachableCodeException(); - } - - return dataProperty.GetTypeCategory() == TypeCategory.NullableReferenceType; + Type relationshipSchemaOpenType = relationshipSchemaType.GetGenericTypeDefinition(); + return NullableRelationshipSchemaOpenTypes.Contains(relationshipSchemaOpenType); } } diff --git a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceObjectSchemaGenerator.cs b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceObjectSchemaGenerator.cs index 616b98ef63..a4fda8d7d0 100644 --- a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceObjectSchemaGenerator.cs +++ b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceObjectSchemaGenerator.cs @@ -31,7 +31,7 @@ public ResourceObjectSchemaGenerator(SchemaGenerator defaultSchemaGenerator, IRe _allowClientGeneratedIds = options.AllowClientGeneratedIds; _resourceFieldObjectSchemaBuilderFactory = resourceTypeInfo => new ResourceFieldObjectSchemaBuilder(resourceTypeInfo, schemaRepositoryAccessor, - defaultSchemaGenerator, _resourceTypeSchemaGenerator, options.SerializerOptions.PropertyNamingPolicy); + defaultSchemaGenerator, _resourceTypeSchemaGenerator, options); } public OpenApiSchema GenerateSchema(Type resourceObjectType) diff --git a/src/JsonApiDotNetCore.OpenApi/TypeCategory.cs b/src/JsonApiDotNetCore.OpenApi/TypeCategory.cs deleted file mode 100644 index 1641e31775..0000000000 --- a/src/JsonApiDotNetCore.OpenApi/TypeCategory.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace JsonApiDotNetCore.OpenApi; - -internal enum TypeCategory -{ - NonNullableReferenceType, - NullableReferenceType, - ValueType, - NullableValueType -} diff --git a/test/OpenApiClientTests/LegacyClient/ApiResponse.cs b/test/OpenApiClientTests/ApiResponse.cs similarity index 94% rename from test/OpenApiClientTests/LegacyClient/ApiResponse.cs rename to test/OpenApiClientTests/ApiResponse.cs index d009d2acee..0c353ea5c0 100644 --- a/test/OpenApiClientTests/LegacyClient/ApiResponse.cs +++ b/test/OpenApiClientTests/ApiResponse.cs @@ -3,7 +3,7 @@ #pragma warning disable AV1008 // Class should not be static -namespace OpenApiClientTests.LegacyClient; +namespace OpenApiClientTests; internal static class ApiResponse { diff --git a/test/OpenApiClientTests/LegacyClient/FakeHttpClientWrapper.cs b/test/OpenApiClientTests/FakeHttpClientWrapper.cs similarity index 98% rename from test/OpenApiClientTests/LegacyClient/FakeHttpClientWrapper.cs rename to test/OpenApiClientTests/FakeHttpClientWrapper.cs index 254f7f31cd..7240daf029 100644 --- a/test/OpenApiClientTests/LegacyClient/FakeHttpClientWrapper.cs +++ b/test/OpenApiClientTests/FakeHttpClientWrapper.cs @@ -3,7 +3,7 @@ using System.Text; using JsonApiDotNetCore.OpenApi.Client; -namespace OpenApiClientTests.LegacyClient; +namespace OpenApiClientTests; /// /// Enables to inject an outgoing response body and inspect the incoming request. diff --git a/test/OpenApiClientTests/OpenApiClientTests.csproj b/test/OpenApiClientTests/OpenApiClientTests.csproj index 3f54c161b8..b16a6f3947 100644 --- a/test/OpenApiClientTests/OpenApiClientTests.csproj +++ b/test/OpenApiClientTests/OpenApiClientTests.csproj @@ -19,10 +19,6 @@ - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -62,5 +58,19 @@ NSwagCSharp /UseBaseUrl:false /ClientClassAccessModifier:internal /GenerateExceptionClasses:false /AdditionalNamespaceUsages:JsonApiDotNetCore.OpenApi.Client.Exceptions + + OpenApiClientTests.SchemaProperties.NullableReferenceTypesEnabled.GeneratedCode + NullableReferenceTypesEnabledClient + NullableReferenceTypesEnabledClient.cs + NSwagCSharp + /UseBaseUrl:false /ClientClassAccessModifier:internal /GenerateExceptionClasses:false /AdditionalNamespaceUsages:JsonApiDotNetCore.OpenApi.Client.Exceptions /GenerateNullableReferenceTypes:true + + + OpenApiClientTests.SchemaProperties.NullableReferenceTypesDisabled.GeneratedCode + NullableReferenceTypesDisabledClient + NullableReferenceTypesDisabledClient.cs + NSwagCSharp + /UseBaseUrl:false /ClientClassAccessModifier:internal /GenerateExceptionClasses:false /AdditionalNamespaceUsages:JsonApiDotNetCore.OpenApi.Client.Exceptions /GenerateNullableReferenceTypes:false + \ No newline at end of file diff --git a/test/OpenApiClientTests/PropertyInfoAssertionsExtension.cs b/test/OpenApiClientTests/PropertyInfoAssertionsExtension.cs new file mode 100644 index 0000000000..c3338a300b --- /dev/null +++ b/test/OpenApiClientTests/PropertyInfoAssertionsExtension.cs @@ -0,0 +1,30 @@ +using System.Reflection; +using FluentAssertions; +using FluentAssertions.Types; + +namespace OpenApiClientTests; + +internal static class PropertyInfoAssertionsExtensions +{ + private static readonly NullabilityInfoContext NullabilityInfoContext = new(); + + [CustomAssertion] + public static void BeNullable(this PropertyInfoAssertions source, string because = "", params object[] becauseArgs) + { + PropertyInfo propertyInfo = source.Subject; + + NullabilityInfo nullabilityInfo = NullabilityInfoContext.Create(propertyInfo); + + nullabilityInfo.ReadState.Should().NotBe(NullabilityState.NotNull, because, becauseArgs); + } + + [CustomAssertion] + public static void BeNonNullable(this PropertyInfoAssertions source, string because = "", params object[] becauseArgs) + { + PropertyInfo propertyInfo = source.Subject; + + NullabilityInfo nullabilityInfo = NullabilityInfoContext.Create(propertyInfo); + + nullabilityInfo.ReadState.Should().Be(NullabilityState.NotNull, because, becauseArgs); + } +} diff --git a/test/OpenApiClientTests/SchemaProperties/NullableReferenceTypesDisabled/GeneratedCode/NullableReferenceTypesDisabledClient.cs b/test/OpenApiClientTests/SchemaProperties/NullableReferenceTypesDisabled/GeneratedCode/NullableReferenceTypesDisabledClient.cs new file mode 100644 index 0000000000..c1a4eebab5 --- /dev/null +++ b/test/OpenApiClientTests/SchemaProperties/NullableReferenceTypesDisabled/GeneratedCode/NullableReferenceTypesDisabledClient.cs @@ -0,0 +1,14 @@ +using JsonApiDotNetCore.OpenApi.Client; +using Newtonsoft.Json; + +namespace OpenApiClientTests.SchemaProperties.NullableReferenceTypesDisabled.GeneratedCode; + +internal partial class NullableReferenceTypesDisabledClient : JsonApiClient +{ + partial void UpdateJsonSerializerSettings(JsonSerializerSettings settings) + { + SetSerializerSettingsForJsonApi(settings); + + settings.Formatting = Formatting.Indented; + } +} diff --git a/test/OpenApiClientTests/SchemaProperties/NullableReferenceTypesDisabled/NullabilityTests.cs b/test/OpenApiClientTests/SchemaProperties/NullableReferenceTypesDisabled/NullabilityTests.cs new file mode 100644 index 0000000000..136c729537 --- /dev/null +++ b/test/OpenApiClientTests/SchemaProperties/NullableReferenceTypesDisabled/NullabilityTests.cs @@ -0,0 +1,27 @@ +using System.Reflection; +using FluentAssertions; +using OpenApiClientTests.SchemaProperties.NullableReferenceTypesDisabled.GeneratedCode; +using Xunit; + +namespace OpenApiClientTests.SchemaProperties.NullableReferenceTypesDisabled; + +public sealed class NullabilityTests +{ + [Fact] + public void Nullability_of_generated_types_is_as_expected() + { + PropertyInfo[] propertyInfos = typeof(ChickenAttributesInResponse).GetProperties(); + + PropertyInfo? propertyInfo = propertyInfos.FirstOrDefault(property => property.Name == nameof(ChickenAttributesInResponse.Name)); + propertyInfo.Should().BeNullable(); + + propertyInfo = propertyInfos.FirstOrDefault(property => property.Name == nameof(ChickenAttributesInResponse.NameOfCurrentFarm)); + propertyInfo.Should().BeNullable(); + + propertyInfo = propertyInfos.FirstOrDefault(property => property.Name == nameof(ChickenAttributesInResponse.Age)); + propertyInfo.Should().BeNonNullable(); + + propertyInfo = propertyInfos.FirstOrDefault(property => property.Name == nameof(ChickenAttributesInResponse.TimeAtCurrentFarmInDays)); + propertyInfo.Should().BeNullable(); + } +} diff --git a/test/OpenApiClientTests/SchemaProperties/NullableReferenceTypesDisabled/RequiredAttributesTests.cs b/test/OpenApiClientTests/SchemaProperties/NullableReferenceTypesDisabled/RequiredAttributesTests.cs new file mode 100644 index 0000000000..b97535f908 --- /dev/null +++ b/test/OpenApiClientTests/SchemaProperties/NullableReferenceTypesDisabled/RequiredAttributesTests.cs @@ -0,0 +1,113 @@ +using System.Net; +using FluentAssertions; +using FluentAssertions.Specialized; +using JsonApiDotNetCore.Middleware; +using Microsoft.Net.Http.Headers; +using Newtonsoft.Json; +using OpenApiClientTests.SchemaProperties.NullableReferenceTypesDisabled.GeneratedCode; +using TestBuildingBlocks; +using Xunit; + +namespace OpenApiClientTests.SchemaProperties.NullableReferenceTypesDisabled; + +public sealed class RequiredAttributesTests +{ + private const string HostPrefix = "http://localhost/"; + + [Fact] + public async Task Partial_posting_resource_with_explicitly_omitting_required_fields_produces_expected_request() + { + // Arrange + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + var apiClient = new NullableReferenceTypesDisabledClient(wrapper.HttpClient); + + var requestDocument = new ChickenPostRequestDocument + { + Data = new ChickenDataInPostRequest + { + Attributes = new ChickenAttributesInPostRequest + { + HasProducedEggs = true + } + } + }; + + using (apiClient.RegisterAttributesForRequestDocument(requestDocument, + chicken => chicken.HasProducedEggs)) + { + // Act + await ApiResponse.TranslateAsync(async () => await apiClient.PostChickenAsync(requestDocument)); + } + + // Assert + wrapper.Request.ShouldNotBeNull(); + wrapper.Request.Headers.GetValue(HeaderNames.Accept).Should().Be(HeaderConstants.MediaType); + wrapper.Request.Method.Should().Be(HttpMethod.Post); + wrapper.Request.RequestUri.Should().Be(HostPrefix + "chickens"); + wrapper.Request.Content.Should().NotBeNull(); + wrapper.Request.Content!.Headers.ContentType.Should().NotBeNull(); + wrapper.Request.Content!.Headers.ContentType!.ToString().Should().Be(HeaderConstants.MediaType); + + wrapper.RequestBody.Should().BeJson(@"{ + ""data"": { + ""type"": ""chickens"", + ""attributes"": { + ""hasProducedEggs"": true + } + } +}"); + } + + [Fact] + public async Task Partial_posting_resource_without_explicitly_omitting_required_fields_fails() + { + // Arrange + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + var apiClient = new NullableReferenceTypesDisabledClient(wrapper.HttpClient); + + var requestDocument = new ChickenPostRequestDocument + { + Data = new ChickenDataInPostRequest + { + Attributes = new ChickenAttributesInPostRequest + { + Weight = 3 + } + } + }; + + // Act + Func> action = async () => + await ApiResponse.TranslateAsync(async () => await apiClient.PostChickenAsync(requestDocument)); + + // Assert + ExceptionAssertions assertion = await action.Should().ThrowExactlyAsync(); + JsonSerializationException exception = assertion.Subject.Single(); + + exception.Message.Should().Be("Cannot write a null value for property 'nameOfCurrentFarm'. Property requires a value. Path 'data.attributes'."); + } + + [Fact] + public async Task Patching_resource_with_missing_id_fails() + { + // Arrange + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + var apiClient = new NullableReferenceTypesDisabledClient(wrapper.HttpClient); + + var requestDocument = new ChickenPatchRequestDocument + { + Data = new ChickenDataInPatchRequest + { + Attributes = new ChickenAttributesInPatchRequest + { + Age = 1 + } + } + }; + + Func action = async () => await ApiResponse.TranslateAsync(async () => await apiClient.PatchChickenAsync(1, requestDocument)); + + // Assert + await action.Should().ThrowAsync(); + } +} diff --git a/test/OpenApiClientTests/SchemaProperties/NullableReferenceTypesDisabled/swagger.g.json b/test/OpenApiClientTests/SchemaProperties/NullableReferenceTypesDisabled/swagger.g.json new file mode 100644 index 0000000000..76771a1783 --- /dev/null +++ b/test/OpenApiClientTests/SchemaProperties/NullableReferenceTypesDisabled/swagger.g.json @@ -0,0 +1,516 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "OpenApiTests", + "version": "1.0" + }, + "paths": { + "/chickens": { + "get": { + "tags": [ + "chickens" + ], + "operationId": "getChickenCollection", + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/chickenCollectionResponseDocument" + } + } + } + } + } + }, + "head": { + "tags": [ + "chickens" + ], + "operationId": "headChickenCollection", + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/chickenCollectionResponseDocument" + } + } + } + } + } + }, + "post": { + "tags": [ + "chickens" + ], + "operationId": "postChicken", + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/chickenPostRequestDocument" + } + } + } + }, + "responses": { + "201": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/chickenPrimaryResponseDocument" + } + } + } + }, + "204": { + "description": "Success" + } + } + } + }, + "/chickens/{id}": { + "get": { + "tags": [ + "chickens" + ], + "operationId": "getChicken", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/chickenPrimaryResponseDocument" + } + } + } + } + } + }, + "head": { + "tags": [ + "chickens" + ], + "operationId": "headChicken", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/chickenPrimaryResponseDocument" + } + } + } + } + } + }, + "patch": { + "tags": [ + "chickens" + ], + "operationId": "patchChicken", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/chickenPatchRequestDocument" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/chickenPrimaryResponseDocument" + } + } + } + }, + "204": { + "description": "Success" + } + } + }, + "delete": { + "tags": [ + "chickens" + ], + "operationId": "deleteChicken", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "Success" + } + } + } + } + }, + "components": { + "schemas": { + "chickenAttributesInPatchRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true + }, + "nameOfCurrentFarm": { + "type": "string" + }, + "age": { + "type": "integer", + "format": "int32" + }, + "weight": { + "type": "integer", + "format": "int32" + }, + "timeAtCurrentFarmInDays": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "hasProducedEggs": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "chickenAttributesInPostRequest": { + "required": [ + "hasProducedEggs", + "nameOfCurrentFarm", + "weight" + ], + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true + }, + "nameOfCurrentFarm": { + "type": "string" + }, + "age": { + "type": "integer", + "format": "int32" + }, + "weight": { + "type": "integer", + "format": "int32" + }, + "timeAtCurrentFarmInDays": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "hasProducedEggs": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "chickenAttributesInResponse": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true + }, + "nameOfCurrentFarm": { + "type": "string" + }, + "age": { + "type": "integer", + "format": "int32" + }, + "weight": { + "type": "integer", + "format": "int32" + }, + "timeAtCurrentFarmInDays": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "hasProducedEggs": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "chickenCollectionResponseDocument": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/chickenDataInResponse" + } + }, + "meta": { + "type": "object", + "additionalProperties": { } + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapiObject" + }, + "links": { + "$ref": "#/components/schemas/linksInResourceCollectionDocument" + } + }, + "additionalProperties": false + }, + "chickenDataInPatchRequest": { + "required": [ + "id", + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/chickenResourceType" + }, + "id": { + "type": "string" + }, + "attributes": { + "$ref": "#/components/schemas/chickenAttributesInPatchRequest" + } + }, + "additionalProperties": false + }, + "chickenDataInPostRequest": { + "required": [ + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/chickenResourceType" + }, + "attributes": { + "$ref": "#/components/schemas/chickenAttributesInPostRequest" + } + }, + "additionalProperties": false + }, + "chickenDataInResponse": { + "required": [ + "id", + "links", + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/chickenResourceType" + }, + "id": { + "type": "string" + }, + "attributes": { + "$ref": "#/components/schemas/chickenAttributesInResponse" + }, + "links": { + "$ref": "#/components/schemas/linksInResourceObject" + }, + "meta": { + "type": "object", + "additionalProperties": { } + } + }, + "additionalProperties": false + }, + "chickenPatchRequestDocument": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/chickenDataInPatchRequest" + } + }, + "additionalProperties": false + }, + "chickenPostRequestDocument": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/chickenDataInPostRequest" + } + }, + "additionalProperties": false + }, + "chickenPrimaryResponseDocument": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/chickenDataInResponse" + }, + "meta": { + "type": "object", + "additionalProperties": { } + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapiObject" + }, + "links": { + "$ref": "#/components/schemas/linksInResourceDocument" + } + }, + "additionalProperties": false + }, + "chickenResourceType": { + "enum": [ + "chickens" + ], + "type": "string" + }, + "jsonapiObject": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "ext": { + "type": "array", + "items": { + "type": "string" + } + }, + "profile": { + "type": "array", + "items": { + "type": "string" + } + }, + "meta": { + "type": "object", + "additionalProperties": { } + } + }, + "additionalProperties": false + }, + "linksInResourceCollectionDocument": { + "required": [ + "first", + "self" + ], + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "first": { + "type": "string" + }, + "last": { + "type": "string" + }, + "prev": { + "type": "string" + }, + "next": { + "type": "string" + } + }, + "additionalProperties": false + }, + "linksInResourceDocument": { + "required": [ + "self" + ], + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "describedby": { + "type": "string" + } + }, + "additionalProperties": false + }, + "linksInResourceObject": { + "required": [ + "self" + ], + "type": "object", + "properties": { + "self": { + "type": "string" + } + }, + "additionalProperties": false + } + } + } +} \ No newline at end of file diff --git a/test/OpenApiClientTests/SchemaProperties/NullableReferenceTypesEnabled/GeneratedCode/NullableReferenceTypesEnabledClient.cs b/test/OpenApiClientTests/SchemaProperties/NullableReferenceTypesEnabled/GeneratedCode/NullableReferenceTypesEnabledClient.cs new file mode 100644 index 0000000000..17b822f56d --- /dev/null +++ b/test/OpenApiClientTests/SchemaProperties/NullableReferenceTypesEnabled/GeneratedCode/NullableReferenceTypesEnabledClient.cs @@ -0,0 +1,14 @@ +using JsonApiDotNetCore.OpenApi.Client; +using Newtonsoft.Json; + +namespace OpenApiClientTests.SchemaProperties.NullableReferenceTypesEnabled.GeneratedCode; + +internal partial class NullableReferenceTypesEnabledClient : JsonApiClient +{ + partial void UpdateJsonSerializerSettings(JsonSerializerSettings settings) + { + SetSerializerSettingsForJsonApi(settings); + + settings.Formatting = Formatting.Indented; + } +} diff --git a/test/OpenApiClientTests/SchemaProperties/NullableReferenceTypesEnabled/NullabilityTests.cs b/test/OpenApiClientTests/SchemaProperties/NullableReferenceTypesEnabled/NullabilityTests.cs new file mode 100644 index 0000000000..dfb1f0a934 --- /dev/null +++ b/test/OpenApiClientTests/SchemaProperties/NullableReferenceTypesEnabled/NullabilityTests.cs @@ -0,0 +1,27 @@ +using System.Reflection; +using FluentAssertions; +using OpenApiClientTests.SchemaProperties.NullableReferenceTypesEnabled.GeneratedCode; +using Xunit; + +namespace OpenApiClientTests.SchemaProperties.NullableReferenceTypesEnabled; + +public sealed class NullabilityTests +{ + [Fact] + public void Nullability_of_generated_types_is_as_expected() + { + PropertyInfo[] propertyInfos = typeof(CowAttributesInResponse).GetProperties(); + + PropertyInfo? propertyInfo = propertyInfos.FirstOrDefault(property => property.Name == nameof(CowAttributesInResponse.Name)); + propertyInfo.Should().BeNonNullable(); + + propertyInfo = propertyInfos.FirstOrDefault(property => property.Name == nameof(CowAttributesInResponse.NameOfPreviousFarm)); + propertyInfo.Should().BeNullable(); + + propertyInfo = propertyInfos.FirstOrDefault(property => property.Name == nameof(CowAttributesInResponse.Age)); + propertyInfo.Should().BeNonNullable(); + + propertyInfo = propertyInfos.FirstOrDefault(property => property.Name == nameof(CowAttributesInResponse.TimeAtCurrentFarmInDays)); + propertyInfo.Should().BeNullable(); + } +} diff --git a/test/OpenApiClientTests/SchemaProperties/NullableReferenceTypesEnabled/RequiredAttributesTests.cs b/test/OpenApiClientTests/SchemaProperties/NullableReferenceTypesEnabled/RequiredAttributesTests.cs new file mode 100644 index 0000000000..5f35ec7f29 --- /dev/null +++ b/test/OpenApiClientTests/SchemaProperties/NullableReferenceTypesEnabled/RequiredAttributesTests.cs @@ -0,0 +1,94 @@ +using System.Net; +using FluentAssertions; +using FluentAssertions.Specialized; +using JsonApiDotNetCore.Middleware; +using Microsoft.Net.Http.Headers; +using Newtonsoft.Json; +using OpenApiClientTests.SchemaProperties.NullableReferenceTypesEnabled.GeneratedCode; +using TestBuildingBlocks; +using Xunit; + +namespace OpenApiClientTests.SchemaProperties.NullableReferenceTypesEnabled; + +public sealed class RequiredAttributesTests +{ + private const string HostPrefix = "http://localhost/"; + + [Fact] + public async Task Partial_posting_resource_with_explicitly_omitting_required_fields_produces_expected_request() + { + // Arrange + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + var apiClient = new NullableReferenceTypesEnabledClient(wrapper.HttpClient); + + var requestDocument = new CowPostRequestDocument + { + Data = new CowDataInPostRequest + { + Attributes = new CowAttributesInPostRequest + { + HasProducedMilk = true, + Weight = 1100 + } + } + }; + + using (apiClient.RegisterAttributesForRequestDocument(requestDocument)) + { + // Act + await ApiResponse.TranslateAsync(async () => await apiClient.PostCowAsync(requestDocument)); + } + + // Assert + wrapper.Request.ShouldNotBeNull(); + wrapper.Request.Headers.GetValue(HeaderNames.Accept).Should().Be(HeaderConstants.MediaType); + wrapper.Request.Method.Should().Be(HttpMethod.Post); + wrapper.Request.RequestUri.Should().Be(HostPrefix + "cows"); + wrapper.Request.Content.Should().NotBeNull(); + wrapper.Request.Content!.Headers.ContentType.Should().NotBeNull(); + wrapper.Request.Content!.Headers.ContentType!.ToString().Should().Be(HeaderConstants.MediaType); + + wrapper.RequestBody.Should().BeJson(@"{ + ""data"": { + ""type"": ""cows"", + ""attributes"": { + ""weight"": 1100, + ""hasProducedMilk"": true + } + } +}"); + } + + [Fact] + public async Task Partial_posting_resource_without_explicitly_omitting_required_fields_produces_expected_request() + { + // Arrange + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.NoContent, null); + var apiClient = new NullableReferenceTypesEnabledClient(wrapper.HttpClient); + + var requestDocument = new CowPostRequestDocument + { + Data = new CowDataInPostRequest + { + Attributes = new CowAttributesInPostRequest + { + Weight = 1100, + Age = 5, + Name = "Cow 1", + NameOfCurrentFarm = "123", + NameOfPreviousFarm = "123" + } + } + }; + + // Act + Func> + action = async () => await ApiResponse.TranslateAsync(async () => await apiClient.PostCowAsync(requestDocument)); + + // Assert + ExceptionAssertions assertion = await action.Should().ThrowExactlyAsync(); + JsonSerializationException exception = assertion.Subject.Single(); + + exception.Message.Should().Be("Cannot write a null value for property 'nickname'. Property requires a value. Path 'data.attributes'."); + } +} diff --git a/test/OpenApiClientTests/SchemaProperties/NullableReferenceTypesEnabled/swagger.g.json b/test/OpenApiClientTests/SchemaProperties/NullableReferenceTypesEnabled/swagger.g.json new file mode 100644 index 0000000000..78e6dc1a73 --- /dev/null +++ b/test/OpenApiClientTests/SchemaProperties/NullableReferenceTypesEnabled/swagger.g.json @@ -0,0 +1,536 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "OpenApiTests", + "version": "1.0" + }, + "paths": { + "/cows": { + "get": { + "tags": [ + "cows" + ], + "operationId": "getCowCollection", + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/cowCollectionResponseDocument" + } + } + } + } + } + }, + "head": { + "tags": [ + "cows" + ], + "operationId": "headCowCollection", + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/cowCollectionResponseDocument" + } + } + } + } + } + }, + "post": { + "tags": [ + "cows" + ], + "operationId": "postCow", + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/cowPostRequestDocument" + } + } + } + }, + "responses": { + "201": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/cowPrimaryResponseDocument" + } + } + } + }, + "204": { + "description": "Success" + } + } + } + }, + "/cows/{id}": { + "get": { + "tags": [ + "cows" + ], + "operationId": "getCow", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/cowPrimaryResponseDocument" + } + } + } + } + } + }, + "head": { + "tags": [ + "cows" + ], + "operationId": "headCow", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/cowPrimaryResponseDocument" + } + } + } + } + } + }, + "patch": { + "tags": [ + "cows" + ], + "operationId": "patchCow", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/cowPatchRequestDocument" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/cowPrimaryResponseDocument" + } + } + } + }, + "204": { + "description": "Success" + } + } + }, + "delete": { + "tags": [ + "cows" + ], + "operationId": "deleteCow", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "204": { + "description": "Success" + } + } + } + } + }, + "components": { + "schemas": { + "cowAttributesInPatchRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "nameOfCurrentFarm": { + "type": "string" + }, + "nameOfPreviousFarm": { + "type": "string", + "nullable": true + }, + "nickname": { + "type": "string" + }, + "age": { + "type": "integer", + "format": "int32" + }, + "weight": { + "type": "integer", + "format": "int32" + }, + "timeAtCurrentFarmInDays": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "hasProducedMilk": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "cowAttributesInPostRequest": { + "required": [ + "hasProducedMilk", + "name", + "nameOfCurrentFarm", + "nickname", + "weight" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "nameOfCurrentFarm": { + "type": "string" + }, + "nameOfPreviousFarm": { + "type": "string", + "nullable": true + }, + "nickname": { + "type": "string" + }, + "age": { + "type": "integer", + "format": "int32" + }, + "weight": { + "type": "integer", + "format": "int32" + }, + "timeAtCurrentFarmInDays": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "hasProducedMilk": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "cowAttributesInResponse": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "nameOfCurrentFarm": { + "type": "string" + }, + "nameOfPreviousFarm": { + "type": "string", + "nullable": true + }, + "nickname": { + "type": "string" + }, + "age": { + "type": "integer", + "format": "int32" + }, + "weight": { + "type": "integer", + "format": "int32" + }, + "timeAtCurrentFarmInDays": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "hasProducedMilk": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "cowCollectionResponseDocument": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/cowDataInResponse" + } + }, + "meta": { + "type": "object", + "additionalProperties": { } + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapiObject" + }, + "links": { + "$ref": "#/components/schemas/linksInResourceCollectionDocument" + } + }, + "additionalProperties": false + }, + "cowDataInPatchRequest": { + "required": [ + "id", + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/cowResourceType" + }, + "id": { + "type": "string" + }, + "attributes": { + "$ref": "#/components/schemas/cowAttributesInPatchRequest" + } + }, + "additionalProperties": false + }, + "cowDataInPostRequest": { + "required": [ + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/cowResourceType" + }, + "attributes": { + "$ref": "#/components/schemas/cowAttributesInPostRequest" + } + }, + "additionalProperties": false + }, + "cowDataInResponse": { + "required": [ + "id", + "links", + "type" + ], + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/cowResourceType" + }, + "id": { + "type": "string" + }, + "attributes": { + "$ref": "#/components/schemas/cowAttributesInResponse" + }, + "links": { + "$ref": "#/components/schemas/linksInResourceObject" + }, + "meta": { + "type": "object", + "additionalProperties": { } + } + }, + "additionalProperties": false + }, + "cowPatchRequestDocument": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/cowDataInPatchRequest" + } + }, + "additionalProperties": false + }, + "cowPostRequestDocument": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/cowDataInPostRequest" + } + }, + "additionalProperties": false + }, + "cowPrimaryResponseDocument": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/cowDataInResponse" + }, + "meta": { + "type": "object", + "additionalProperties": { } + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapiObject" + }, + "links": { + "$ref": "#/components/schemas/linksInResourceDocument" + } + }, + "additionalProperties": false + }, + "cowResourceType": { + "enum": [ + "cows" + ], + "type": "string" + }, + "jsonapiObject": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "ext": { + "type": "array", + "items": { + "type": "string" + } + }, + "profile": { + "type": "array", + "items": { + "type": "string" + } + }, + "meta": { + "type": "object", + "additionalProperties": { } + } + }, + "additionalProperties": false + }, + "linksInResourceCollectionDocument": { + "required": [ + "first", + "self" + ], + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "first": { + "type": "string" + }, + "last": { + "type": "string" + }, + "prev": { + "type": "string" + }, + "next": { + "type": "string" + } + }, + "additionalProperties": false + }, + "linksInResourceDocument": { + "required": [ + "self" + ], + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "describedby": { + "type": "string" + } + }, + "additionalProperties": false + }, + "linksInResourceObject": { + "required": [ + "self" + ], + "type": "object", + "properties": { + "self": { + "type": "string" + } + }, + "additionalProperties": false + } + } + } +} \ No newline at end of file diff --git a/test/OpenApiTests/JsonElementExtensions.cs b/test/OpenApiTests/JsonElementExtensions.cs index d8dc7d4e20..6f1ffe1432 100644 --- a/test/OpenApiTests/JsonElementExtensions.cs +++ b/test/OpenApiTests/JsonElementExtensions.cs @@ -19,6 +19,13 @@ public static JsonElement ShouldContainPath(this JsonElement source, string path return elementSelector.Should().NotThrow().Subject; } + public static void ShouldNotContainPath(this JsonElement source, string path) + { + JsonElement? pathToken = source.SelectToken(path); + + pathToken.Should().BeNull(); + } + public static void ShouldBeString(this JsonElement source, string value) { source.ValueKind.Should().Be(JsonValueKind.String); diff --git a/test/OpenApiTests/OpenApiTestContext.cs b/test/OpenApiTests/OpenApiTestContext.cs index a750929827..9d4a95c33d 100644 --- a/test/OpenApiTests/OpenApiTestContext.cs +++ b/test/OpenApiTests/OpenApiTestContext.cs @@ -28,34 +28,27 @@ internal async Task GetSwaggerDocumentAsync() private async Task CreateSwaggerDocumentAsync() { - string absoluteOutputPath = GetSwaggerDocumentAbsoluteOutputPath(SwaggerDocumentOutputPath); - string content = await GetAsync("swagger/v1/swagger.json"); JsonElement rootElement = ParseSwaggerDocument(content); - await WriteToDiskAsync(absoluteOutputPath, rootElement); + + if (SwaggerDocumentOutputPath != null) + { + string absoluteOutputPath = GetSwaggerDocumentAbsoluteOutputPath(SwaggerDocumentOutputPath); + await WriteToDiskAsync(absoluteOutputPath, rootElement); + } return rootElement; } - private static string GetSwaggerDocumentAbsoluteOutputPath(string? relativePath) + private static string GetSwaggerDocumentAbsoluteOutputPath(string relativePath) { - AssertHasSwaggerDocumentOutputPath(relativePath); - string solutionRoot = Path.Combine(Assembly.GetExecutingAssembly().Location, "../../../../../../"); string outputPath = Path.Combine(solutionRoot, relativePath, "swagger.g.json"); return Path.GetFullPath(outputPath); } - private static void AssertHasSwaggerDocumentOutputPath([SysNotNull] string? relativePath) - { - if (relativePath is null) - { - throw new Exception($"Property '{nameof(OpenApiTestContext)}.{nameof(SwaggerDocumentOutputPath)}' must be set."); - } - } - private async Task GetAsync(string requestUrl) { using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl); diff --git a/test/OpenApiTests/SchemaProperties/ModelStateValidationDisabledStartup.cs b/test/OpenApiTests/SchemaProperties/ModelStateValidationDisabledStartup.cs new file mode 100644 index 0000000000..9895d7d61b --- /dev/null +++ b/test/OpenApiTests/SchemaProperties/ModelStateValidationDisabledStartup.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore; + +namespace OpenApiTests.SchemaProperties; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class ModelStateValidationDisabledStartup : OpenApiStartup + where TDbContext : DbContext +{ + protected override void SetJsonApiOptions(JsonApiOptions options) + { + base.SetJsonApiOptions(options); + + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); + options.ValidateModelState = false; + } +} diff --git a/test/OpenApiTests/SchemaProperties/NullableReferenceTypesDisabled/Chicken.cs b/test/OpenApiTests/SchemaProperties/NullableReferenceTypesDisabled/Chicken.cs new file mode 100644 index 0000000000..4865d33af9 --- /dev/null +++ b/test/OpenApiTests/SchemaProperties/NullableReferenceTypesDisabled/Chicken.cs @@ -0,0 +1,34 @@ +#nullable disable + +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace OpenApiTests.SchemaProperties.NullableReferenceTypesDisabled; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "OpenApiTests.SchemaProperties")] +public sealed class Chicken : Identifiable +{ + [Attr] + public string Name { get; set; } + + [Attr] + [Required] + public string NameOfCurrentFarm { get; set; } + + [Attr] + public int Age { get; set; } + + [Attr] + [Required] + public int Weight { get; set; } + + [Attr] + public int? TimeAtCurrentFarmInDays { get; set; } + + [Attr] + [Required] + public bool? HasProducedEggs { get; set; } +} diff --git a/test/OpenApiTests/SchemaProperties/NullableReferenceTypesDisabled/ModelStateValidationDisabledTests.cs b/test/OpenApiTests/SchemaProperties/NullableReferenceTypesDisabled/ModelStateValidationDisabledTests.cs new file mode 100644 index 0000000000..838e2a6bc2 --- /dev/null +++ b/test/OpenApiTests/SchemaProperties/NullableReferenceTypesDisabled/ModelStateValidationDisabledTests.cs @@ -0,0 +1,40 @@ +using System.Text.Json; +using FluentAssertions; +using TestBuildingBlocks; +using Xunit; + +namespace OpenApiTests.SchemaProperties.NullableReferenceTypesDisabled; + +public sealed class ModelStateValidationDisabledTests + : IClassFixture, NullableReferenceTypesDisabledDbContext>> +{ + private readonly OpenApiTestContext, NullableReferenceTypesDisabledDbContext> + _testContext; + + public ModelStateValidationDisabledTests( + OpenApiTestContext, NullableReferenceTypesDisabledDbContext> testContext) + { + _testContext = testContext; + testContext.UseController(); + } + + [Fact] + public async Task Resource_when_ModelStateValidation_is_disabled_produces_expected_required_property_in_schema() + { + // Act + JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + + // Assert + document.ShouldContainPath("components.schemas.chickenAttributesInPostRequest.required").With(requiredElement => + { + var requiredAttributes = JsonSerializer.Deserialize>(requiredElement.GetRawText()); + requiredAttributes.ShouldNotBeNull(); + + requiredAttributes.Should().Contain("nameOfCurrentFarm"); + requiredAttributes.Should().Contain("weight"); + requiredAttributes.Should().Contain("hasProducedEggs"); + + requiredAttributes.ShouldHaveCount(3); + }); + } +} diff --git a/test/OpenApiTests/SchemaProperties/NullableReferenceTypesDisabled/ModelStateValidationEnabledTests.cs b/test/OpenApiTests/SchemaProperties/NullableReferenceTypesDisabled/ModelStateValidationEnabledTests.cs new file mode 100644 index 0000000000..b1c39ca090 --- /dev/null +++ b/test/OpenApiTests/SchemaProperties/NullableReferenceTypesDisabled/ModelStateValidationEnabledTests.cs @@ -0,0 +1,40 @@ +using System.Text.Json; +using FluentAssertions; +using TestBuildingBlocks; +using Xunit; + +namespace OpenApiTests.SchemaProperties.NullableReferenceTypesDisabled; + +public sealed class ModelStateValidationEnabledTests + : IClassFixture, NullableReferenceTypesDisabledDbContext>> +{ + private readonly OpenApiTestContext, NullableReferenceTypesDisabledDbContext> _testContext; + + public ModelStateValidationEnabledTests( + OpenApiTestContext, NullableReferenceTypesDisabledDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + } + + [Fact] + public async Task Resource_when_ModelStateValidation_is_enabled_produces_expected_required_property_in_schema() + { + // Act + JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + + // Assert + document.ShouldContainPath("components.schemas.chickenAttributesInPostRequest.required").With(requiredElement => + { + var requiredAttributes = JsonSerializer.Deserialize>(requiredElement.GetRawText()); + requiredAttributes.ShouldNotBeNull(); + + requiredAttributes.Should().Contain("nameOfCurrentFarm"); + requiredAttributes.Should().Contain("weight"); + requiredAttributes.Should().Contain("hasProducedEggs"); + + requiredAttributes.ShouldHaveCount(3); + }); + } +} diff --git a/test/OpenApiTests/SchemaProperties/NullableReferenceTypesDisabled/NullabilityTests.cs b/test/OpenApiTests/SchemaProperties/NullableReferenceTypesDisabled/NullabilityTests.cs new file mode 100644 index 0000000000..e13be25d25 --- /dev/null +++ b/test/OpenApiTests/SchemaProperties/NullableReferenceTypesDisabled/NullabilityTests.cs @@ -0,0 +1,62 @@ +using System.Text.Json; +using FluentAssertions; +using TestBuildingBlocks; +using Xunit; + +namespace OpenApiTests.SchemaProperties.NullableReferenceTypesDisabled; + +public sealed class NullabilityTests + : IClassFixture, NullableReferenceTypesDisabledDbContext>> +{ + private readonly OpenApiTestContext, NullableReferenceTypesDisabledDbContext> _testContext; + + public NullabilityTests( + OpenApiTestContext, NullableReferenceTypesDisabledDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.SwaggerDocumentOutputPath = "test/OpenApiClientTests/SchemaProperties/NullableReferenceTypesDisabled"; + } + + [Fact] + public async Task Resource_produces_expected_nullable_properties_in_schema() + { + // Act + JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + + // Assert + document.ShouldContainPath("components.schemas.chickenAttributesInResponse.properties").With(propertiesElement => + { + propertiesElement.ShouldContainPath("name").With(propertyElement => + { + propertyElement.ShouldContainPath("nullable").With(nullableProperty => nullableProperty.ValueKind.Should().Be(JsonValueKind.True)); + }); + + propertiesElement.ShouldContainPath("nameOfCurrentFarm").With(propertyElement => + { + propertyElement.ShouldNotContainPath("nullable"); + }); + + propertiesElement.ShouldContainPath("age").With(propertyElement => + { + propertyElement.ShouldNotContainPath("nullable"); + }); + + propertiesElement.ShouldContainPath("weight").With(propertyElement => + { + propertyElement.ShouldNotContainPath("nullable"); + }); + + propertiesElement.ShouldContainPath("timeAtCurrentFarmInDays").With(propertyElement => + { + propertyElement.ShouldContainPath("nullable").With(nullableProperty => nullableProperty.ValueKind.Should().Be(JsonValueKind.True)); + }); + + propertiesElement.ShouldContainPath("hasProducedEggs").With(propertyElement => + { + propertyElement.ShouldNotContainPath("nullable"); + }); + }); + } +} diff --git a/test/OpenApiTests/SchemaProperties/NullableReferenceTypesDisabled/NullableReferenceTypesDisabledDbContext.cs b/test/OpenApiTests/SchemaProperties/NullableReferenceTypesDisabled/NullableReferenceTypesDisabledDbContext.cs new file mode 100644 index 0000000000..1208a369ef --- /dev/null +++ b/test/OpenApiTests/SchemaProperties/NullableReferenceTypesDisabled/NullableReferenceTypesDisabledDbContext.cs @@ -0,0 +1,15 @@ +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; + +namespace OpenApiTests.SchemaProperties.NullableReferenceTypesDisabled; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class NullableReferenceTypesDisabledDbContext : DbContext +{ + public DbSet Chicken => Set(); + + public NullableReferenceTypesDisabledDbContext(DbContextOptions options) + : base(options) + { + } +} diff --git a/test/OpenApiTests/SchemaProperties/NullableReferenceTypesEnabled/Cow.cs b/test/OpenApiTests/SchemaProperties/NullableReferenceTypesEnabled/Cow.cs new file mode 100644 index 0000000000..3b5562a0b3 --- /dev/null +++ b/test/OpenApiTests/SchemaProperties/NullableReferenceTypesEnabled/Cow.cs @@ -0,0 +1,39 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace OpenApiTests.SchemaProperties.NullableReferenceTypesEnabled; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "OpenApiTests.SchemaProperties")] +public sealed class Cow : Identifiable +{ + [Attr] + public string Name { get; set; } = null!; + + [Attr] + [Required] + public string NameOfCurrentFarm { get; set; } = null!; + + [Attr] + public string? NameOfPreviousFarm { get; set; } + + [Attr] + [Required] + public string? Nickname { get; set; } + + [Attr] + public int Age { get; set; } + + [Attr] + [Required] + public int Weight { get; set; } + + [Attr] + public int? TimeAtCurrentFarmInDays { get; set; } + + [Attr] + [Required] + public bool? HasProducedMilk { get; set; } +} diff --git a/test/OpenApiTests/SchemaProperties/NullableReferenceTypesEnabled/ModelStateValidationDisabledTests.cs b/test/OpenApiTests/SchemaProperties/NullableReferenceTypesEnabled/ModelStateValidationDisabledTests.cs new file mode 100644 index 0000000000..f57c3bf4bc --- /dev/null +++ b/test/OpenApiTests/SchemaProperties/NullableReferenceTypesEnabled/ModelStateValidationDisabledTests.cs @@ -0,0 +1,42 @@ +using System.Text.Json; +using FluentAssertions; +using TestBuildingBlocks; +using Xunit; + +namespace OpenApiTests.SchemaProperties.NullableReferenceTypesEnabled; + +public sealed class ModelStateValidationDisabledTests + : IClassFixture, NullableReferenceTypesEnabledDbContext>> +{ + private readonly OpenApiTestContext, NullableReferenceTypesEnabledDbContext> + _testContext; + + public ModelStateValidationDisabledTests( + OpenApiTestContext, NullableReferenceTypesEnabledDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + } + + [Fact] + public async Task Resource_when_ModelStateValidation_is_disabled_produces_expected_required_property_in_schema() + { + // Act + JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + + // Assert + document.ShouldContainPath("components.schemas.cowAttributesInPostRequest.required").With(requiredElement => + { + var requiredAttributes = JsonSerializer.Deserialize>(requiredElement.GetRawText()); + requiredAttributes.ShouldNotBeNull(); + + requiredAttributes.Should().Contain("nameOfCurrentFarm"); + requiredAttributes.Should().Contain("nickname"); + requiredAttributes.Should().Contain("weight"); + requiredAttributes.Should().Contain("hasProducedMilk"); + + requiredAttributes.ShouldHaveCount(4); + }); + } +} diff --git a/test/OpenApiTests/SchemaProperties/NullableReferenceTypesEnabled/ModelStateValidationEnabledTests.cs b/test/OpenApiTests/SchemaProperties/NullableReferenceTypesEnabled/ModelStateValidationEnabledTests.cs new file mode 100644 index 0000000000..7cb98fceda --- /dev/null +++ b/test/OpenApiTests/SchemaProperties/NullableReferenceTypesEnabled/ModelStateValidationEnabledTests.cs @@ -0,0 +1,42 @@ +using System.Text.Json; +using FluentAssertions; +using TestBuildingBlocks; +using Xunit; + +namespace OpenApiTests.SchemaProperties.NullableReferenceTypesEnabled; + +public sealed class ModelStateValidationEnabledTests + : IClassFixture, NullableReferenceTypesEnabledDbContext>> +{ + private readonly OpenApiTestContext, NullableReferenceTypesEnabledDbContext> _testContext; + + public ModelStateValidationEnabledTests( + OpenApiTestContext, NullableReferenceTypesEnabledDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + } + + [Fact] + public async Task Resource_when_ModelStateValidation_is_enabled_produces_expected_required_property_in_schema() + { + // Act + JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + + // Assert + document.ShouldContainPath("components.schemas.cowAttributesInPostRequest.required").With(requiredElement => + { + var requiredAttributes = JsonSerializer.Deserialize>(requiredElement.GetRawText()); + requiredAttributes.ShouldNotBeNull(); + + requiredAttributes.Should().Contain("name"); + requiredAttributes.Should().Contain("nameOfCurrentFarm"); + requiredAttributes.Should().Contain("nickname"); + requiredAttributes.Should().Contain("weight"); + requiredAttributes.Should().Contain("hasProducedMilk"); + + requiredAttributes.ShouldHaveCount(5); + }); + } +} diff --git a/test/OpenApiTests/SchemaProperties/NullableReferenceTypesEnabled/NullabilityTests.cs b/test/OpenApiTests/SchemaProperties/NullableReferenceTypesEnabled/NullabilityTests.cs new file mode 100644 index 0000000000..b69bb69d12 --- /dev/null +++ b/test/OpenApiTests/SchemaProperties/NullableReferenceTypesEnabled/NullabilityTests.cs @@ -0,0 +1,72 @@ +using System.Text.Json; +using FluentAssertions; +using TestBuildingBlocks; +using Xunit; + +namespace OpenApiTests.SchemaProperties.NullableReferenceTypesEnabled; + +public sealed class NullabilityTests + : IClassFixture, NullableReferenceTypesEnabledDbContext>> +{ + private readonly OpenApiTestContext, NullableReferenceTypesEnabledDbContext> _testContext; + + public NullabilityTests( + OpenApiTestContext, NullableReferenceTypesEnabledDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.SwaggerDocumentOutputPath = "test/OpenApiClientTests/SchemaProperties/NullableReferenceTypesEnabled"; + } + + [Fact] + public async Task Resource_produces_expected_nullable_properties_in_schema() + { + // Act + JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + + // Assert + document.ShouldContainPath("components.schemas.cowAttributesInResponse.properties").With(propertiesElement => + { + propertiesElement.ShouldContainPath("name").With(propertyElement => + { + propertyElement.ShouldNotContainPath("nullable"); + }); + + propertiesElement.ShouldContainPath("nameOfCurrentFarm").With(propertyElement => + { + propertyElement.ShouldNotContainPath("nullable"); + }); + + propertiesElement.ShouldContainPath("nameOfPreviousFarm").With(propertyElement => + { + propertyElement.ShouldContainPath("nullable").With(nullableProperty => nullableProperty.ValueKind.Should().Be(JsonValueKind.True)); + }); + + propertiesElement.ShouldContainPath("nickname").With(propertyElement => + { + propertyElement.ShouldNotContainPath("nullable"); + }); + + propertiesElement.ShouldContainPath("age").With(propertyElement => + { + propertyElement.ShouldNotContainPath("nullable"); + }); + + propertiesElement.ShouldContainPath("weight").With(propertyElement => + { + propertyElement.ShouldNotContainPath("nullable"); + }); + + propertiesElement.ShouldContainPath("timeAtCurrentFarmInDays").With(propertyElement => + { + propertyElement.ShouldContainPath("nullable").With(nullableProperty => nullableProperty.ValueKind.Should().Be(JsonValueKind.True)); + }); + + propertiesElement.ShouldContainPath("hasProducedMilk").With(propertyElement => + { + propertyElement.ShouldNotContainPath("nullable"); + }); + }); + } +} diff --git a/test/OpenApiTests/SchemaProperties/NullableReferenceTypesEnabled/NullableReferenceTypesEnabledDbContext.cs b/test/OpenApiTests/SchemaProperties/NullableReferenceTypesEnabled/NullableReferenceTypesEnabledDbContext.cs new file mode 100644 index 0000000000..01c61aa589 --- /dev/null +++ b/test/OpenApiTests/SchemaProperties/NullableReferenceTypesEnabled/NullableReferenceTypesEnabledDbContext.cs @@ -0,0 +1,15 @@ +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; + +namespace OpenApiTests.SchemaProperties.NullableReferenceTypesEnabled; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class NullableReferenceTypesEnabledDbContext : DbContext +{ + public DbSet Cow => Set(); + + public NullableReferenceTypesEnabledDbContext(DbContextOptions options) + : base(options) + { + } +} diff --git a/test/OpenApiTests/SchemaProperties/SchemaPropertiesStartup.cs b/test/OpenApiTests/SchemaProperties/SchemaPropertiesStartup.cs new file mode 100644 index 0000000000..d4a1bf1d19 --- /dev/null +++ b/test/OpenApiTests/SchemaProperties/SchemaPropertiesStartup.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore; + +namespace OpenApiTests.SchemaProperties; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class SchemaPropertiesStartup : OpenApiStartup + where TDbContext : DbContext +{ + protected override void SetJsonApiOptions(JsonApiOptions options) + { + base.SetJsonApiOptions(options); + + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); + } +}