diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiActionDescriptorCollectionProvider.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiActionDescriptorCollectionProvider.cs index 9a988351b5..3feafcef48 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiActionDescriptorCollectionProvider.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiActionDescriptorCollectionProvider.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; -using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.OpenApi.JsonApiMetadata; using Microsoft.AspNetCore.Mvc; @@ -26,15 +25,14 @@ internal sealed class JsonApiActionDescriptorCollectionProvider : IActionDescrip public ActionDescriptorCollection ActionDescriptors => GetActionDescriptors(); - public JsonApiActionDescriptorCollectionProvider(IResourceGraph resourceGraph, IControllerResourceMapping controllerResourceMapping, + public JsonApiActionDescriptorCollectionProvider(IControllerResourceMapping controllerResourceMapping, IActionDescriptorCollectionProvider defaultProvider) { - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); ArgumentGuard.NotNull(controllerResourceMapping, nameof(controllerResourceMapping)); ArgumentGuard.NotNull(defaultProvider, nameof(defaultProvider)); _defaultProvider = defaultProvider; - _jsonApiEndpointMetadataProvider = new JsonApiEndpointMetadataProvider(resourceGraph, controllerResourceMapping); + _jsonApiEndpointMetadataProvider = new JsonApiEndpointMetadataProvider(controllerResourceMapping); } private ActionDescriptorCollection GetActionDescriptors() @@ -67,27 +65,26 @@ private static bool IsVisibleJsonApiEndpoint(ActionDescriptor descriptor) return descriptor is ControllerActionDescriptor controllerAction && controllerAction.Properties.ContainsKey(typeof(ApiDescriptionActionData)); } - private static IList AddJsonApiMetadataToAction(ActionDescriptor endpoint, IJsonApiEndpointMetadata? jsonApiEndpointMetadata) + private static IEnumerable AddJsonApiMetadataToAction(ActionDescriptor endpoint, IJsonApiEndpointMetadata? jsonApiEndpointMetadata) { switch (jsonApiEndpointMetadata) { case PrimaryResponseMetadata primaryMetadata: { - UpdateProducesResponseTypeAttribute(endpoint, primaryMetadata.Type); + UpdateProducesResponseTypeAttribute(endpoint, primaryMetadata.DocumentType); return Array.Empty(); } case PrimaryRequestMetadata primaryMetadata: { - UpdateBodyParameterDescriptor(endpoint, primaryMetadata.Type); + UpdateBodyParameterDescriptor(endpoint, primaryMetadata.DocumentType); return Array.Empty(); } - case ExpansibleEndpointMetadata expansibleMetadata - when expansibleMetadata is RelationshipResponseMetadata || expansibleMetadata is SecondaryResponseMetadata: + case ExpansibleEndpointMetadata expansibleMetadata and (RelationshipResponseMetadata or SecondaryResponseMetadata): { return Expand(endpoint, expansibleMetadata, - (expandedEndpoint, relationshipType, _) => UpdateProducesResponseTypeAttribute(expandedEndpoint, relationshipType)); + (expandedEndpoint, documentType, _) => UpdateProducesResponseTypeAttribute(expandedEndpoint, documentType)); } - case ExpansibleEndpointMetadata expansibleMetadata when expansibleMetadata is RelationshipRequestMetadata: + case ExpansibleEndpointMetadata expansibleMetadata and RelationshipRequestMetadata: { return Expand(endpoint, expansibleMetadata, UpdateBodyParameterDescriptor); } @@ -98,16 +95,15 @@ private static IList AddJsonApiMetadataToAction(ActionDescript } } - private static void UpdateProducesResponseTypeAttribute(ActionDescriptor endpoint, Type responseTypeToSet) + private static void UpdateProducesResponseTypeAttribute(ActionDescriptor endpoint, Type responseDocumentType) { - if (ProducesJsonApiResponseBody(endpoint)) + if (ProducesJsonApiResponseDocument(endpoint)) { var producesResponse = endpoint.GetFilterMetadata(); if (producesResponse != null) { - producesResponse.Type = responseTypeToSet; - + producesResponse.Type = responseDocumentType; return; } } @@ -115,31 +111,32 @@ private static void UpdateProducesResponseTypeAttribute(ActionDescriptor endpoin throw new UnreachableCodeException(); } - private static bool ProducesJsonApiResponseBody(ActionDescriptor endpoint) + private static bool ProducesJsonApiResponseDocument(ActionDescriptor endpoint) { var produces = endpoint.GetFilterMetadata(); return produces != null && produces.ContentTypes.Any(contentType => contentType == HeaderConstants.MediaType); } - private static IList Expand(ActionDescriptor genericEndpoint, ExpansibleEndpointMetadata metadata, + private static IEnumerable Expand(ActionDescriptor genericEndpoint, ExpansibleEndpointMetadata metadata, Action expansionCallback) { var expansion = new List(); - foreach ((string relationshipName, Type relationshipType) in metadata.ExpansionElements) + foreach ((string relationshipName, Type documentType) in metadata.DocumentTypesByRelationshipName) { - ActionDescriptor expandedEndpoint = Clone(genericEndpoint); - RemovePathParameter(expandedEndpoint.Parameters, JsonApiPathParameter.RelationshipName); - - if (expandedEndpoint.AttributeRouteInfo == null) + if (genericEndpoint.AttributeRouteInfo == null) { - throw new NotSupportedException("Only attribute based routing is supported for JsonApiDotNetCore endpoints"); + throw new NotSupportedException("Only attribute routing is supported for JsonApiDotNetCore endpoints."); } - ExpandTemplate(expandedEndpoint.AttributeRouteInfo, relationshipName); + ActionDescriptor expandedEndpoint = Clone(genericEndpoint); + + RemovePathParameter(expandedEndpoint.Parameters, JsonApiPathParameter.RelationshipName); + + ExpandTemplate(expandedEndpoint.AttributeRouteInfo!, relationshipName); - expansionCallback(expandedEndpoint, relationshipType, relationshipName); + expansionCallback(expandedEndpoint, documentType, relationshipName); expansion.Add(expandedEndpoint); } @@ -147,7 +144,7 @@ private static IList Expand(ActionDescriptor genericEndpoint, return expansion; } - private static void UpdateBodyParameterDescriptor(ActionDescriptor endpoint, Type bodyType, string? parameterName = null) + private static void UpdateBodyParameterDescriptor(ActionDescriptor endpoint, Type documentType, string? parameterName = null) { ControllerParameterDescriptor? requestBodyDescriptor = endpoint.GetBodyParameterDescriptor(); @@ -157,8 +154,8 @@ private static void UpdateBodyParameterDescriptor(ActionDescriptor endpoint, Typ throw new UnreachableCodeException(); } - requestBodyDescriptor.ParameterType = bodyType; - ParameterInfo replacementParameterInfo = requestBodyDescriptor.ParameterInfo.WithParameterType(bodyType); + requestBodyDescriptor.ParameterType = documentType; + ParameterInfo replacementParameterInfo = requestBodyDescriptor.ParameterInfo.WithParameterType(documentType); if (parameterName != null) { diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/ExpansibleEndpointMetadata.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/ExpansibleEndpointMetadata.cs index 279abddf9c..f4c718e804 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/ExpansibleEndpointMetadata.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/ExpansibleEndpointMetadata.cs @@ -5,6 +5,13 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata { internal abstract class ExpansibleEndpointMetadata { - public abstract IDictionary ExpansionElements { get; } + public IDictionary DocumentTypesByRelationshipName { get; } + + protected ExpansibleEndpointMetadata(IDictionary documentTypesByRelationshipName) + { + ArgumentGuard.NotNull(documentTypesByRelationshipName, nameof(documentTypesByRelationshipName)); + + DocumentTypesByRelationshipName = documentTypesByRelationshipName; + } } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs index b3b939c709..5af19ff0c8 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs @@ -4,8 +4,8 @@ using System.Reflection; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.OpenApi.JsonApiObjects; using JsonApiDotNetCore.OpenApi.JsonApiObjects.Documents; -using JsonApiDotNetCore.OpenApi.JsonApiObjects.RelationshipData; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata @@ -16,16 +16,14 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata /// internal sealed class JsonApiEndpointMetadataProvider { - private readonly IResourceGraph _resourceGraph; private readonly IControllerResourceMapping _controllerResourceMapping; private readonly EndpointResolver _endpointResolver = new(); + private readonly NonPrimaryDocumentTypeFactory _nonPrimaryDocumentTypeFactory = new(); - public JsonApiEndpointMetadataProvider(IResourceGraph resourceGraph, IControllerResourceMapping controllerResourceMapping) + public JsonApiEndpointMetadataProvider(IControllerResourceMapping controllerResourceMapping) { - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); ArgumentGuard.NotNull(controllerResourceMapping, nameof(controllerResourceMapping)); - _resourceGraph = resourceGraph; _controllerResourceMapping = controllerResourceMapping; } @@ -47,28 +45,28 @@ public JsonApiEndpointMetadataContainer Get(MethodInfo controllerAction) throw new UnreachableCodeException(); } - IJsonApiRequestMetadata? requestMetadata = GetRequestMetadata(endpoint.Value, primaryResourceType.ClrType); - IJsonApiResponseMetadata? responseMetadata = GetResponseMetadata(endpoint.Value, primaryResourceType.ClrType); + IJsonApiRequestMetadata? requestMetadata = GetRequestMetadata(endpoint.Value, primaryResourceType); + IJsonApiResponseMetadata? responseMetadata = GetResponseMetadata(endpoint.Value, primaryResourceType); return new JsonApiEndpointMetadataContainer(requestMetadata, responseMetadata); } - private IJsonApiRequestMetadata? GetRequestMetadata(JsonApiEndpoint endpoint, Type primaryResourceType) + private IJsonApiRequestMetadata? GetRequestMetadata(JsonApiEndpoint endpoint, ResourceType primaryResourceType) { switch (endpoint) { case JsonApiEndpoint.Post: { - return GetPostRequestMetadata(primaryResourceType); + return GetPostRequestMetadata(primaryResourceType.ClrType); } case JsonApiEndpoint.Patch: { - return GetPatchRequestMetadata(primaryResourceType); + return GetPatchRequestMetadata(primaryResourceType.ClrType); } case JsonApiEndpoint.PostRelationship: case JsonApiEndpoint.PatchRelationship: case JsonApiEndpoint.DeleteRelationship: { - return GetRelationshipRequestMetadata(primaryResourceType, endpoint != JsonApiEndpoint.PatchRelationship); + return GetRelationshipRequestMetadata(primaryResourceType.Relationships, endpoint != JsonApiEndpoint.PatchRelationship); } default: { @@ -77,38 +75,31 @@ public JsonApiEndpointMetadataContainer Get(MethodInfo controllerAction) } } - private static PrimaryRequestMetadata GetPostRequestMetadata(Type primaryResourceType) + private static PrimaryRequestMetadata GetPostRequestMetadata(Type resourceClrType) { - Type documentType = typeof(ResourcePostRequestDocument<>).MakeGenericType(primaryResourceType); + Type documentType = typeof(ResourcePostRequestDocument<>).MakeGenericType(resourceClrType); return new PrimaryRequestMetadata(documentType); } - private static PrimaryRequestMetadata GetPatchRequestMetadata(Type primaryResourceType) + private static PrimaryRequestMetadata GetPatchRequestMetadata(Type resourceClrType) { - Type documentType = typeof(ResourcePatchRequestDocument<>).MakeGenericType(primaryResourceType); + Type documentType = typeof(ResourcePatchRequestDocument<>).MakeGenericType(resourceClrType); return new PrimaryRequestMetadata(documentType); } - private RelationshipRequestMetadata GetRelationshipRequestMetadata(Type primaryResourceType, bool ignoreHasOneRelationships) + private RelationshipRequestMetadata GetRelationshipRequestMetadata(IEnumerable relationships, bool ignoreHasOneRelationships) { - IEnumerable relationships = _resourceGraph.GetResourceType(primaryResourceType).Relationships; + IEnumerable relationshipsOfEndpoint = ignoreHasOneRelationships ? relationships.OfType() : relationships; - if (ignoreHasOneRelationships) - { - relationships = relationships.OfType(); - } - - IDictionary resourceTypesByRelationshipName = relationships.ToDictionary(relationship => relationship.PublicName, - relationship => relationship is HasManyAttribute - ? typeof(ToManyRelationshipRequestData<>).MakeGenericType(relationship.RightType.ClrType) - : typeof(ToOneRelationshipRequestData<>).MakeGenericType(relationship.RightType.ClrType)); + IDictionary requestDocumentTypesByRelationshipName = relationshipsOfEndpoint.ToDictionary(relationship => relationship.PublicName, + _nonPrimaryDocumentTypeFactory.GetForRelationshipRequest); - return new RelationshipRequestMetadata(resourceTypesByRelationshipName); + return new RelationshipRequestMetadata(requestDocumentTypesByRelationshipName); } - private IJsonApiResponseMetadata? GetResponseMetadata(JsonApiEndpoint endpoint, Type primaryResourceType) + private IJsonApiResponseMetadata? GetResponseMetadata(JsonApiEndpoint endpoint, ResourceType primaryResourceType) { switch (endpoint) { @@ -117,15 +108,15 @@ private RelationshipRequestMetadata GetRelationshipRequestMetadata(Type primaryR case JsonApiEndpoint.Post: case JsonApiEndpoint.Patch: { - return GetPrimaryResponseMetadata(primaryResourceType, endpoint == JsonApiEndpoint.GetCollection); + return GetPrimaryResponseMetadata(primaryResourceType.ClrType, endpoint == JsonApiEndpoint.GetCollection); } case JsonApiEndpoint.GetSecondary: { - return GetSecondaryResponseMetadata(primaryResourceType); + return GetSecondaryResponseMetadata(primaryResourceType.Relationships); } case JsonApiEndpoint.GetRelationship: { - return GetRelationshipResponseMetadata(primaryResourceType); + return GetRelationshipResponseMetadata(primaryResourceType.Relationships); } default: { @@ -134,44 +125,28 @@ private RelationshipRequestMetadata GetRelationshipRequestMetadata(Type primaryR } } - private static PrimaryResponseMetadata GetPrimaryResponseMetadata(Type primaryResourceType, bool endpointReturnsCollection) + private static PrimaryResponseMetadata GetPrimaryResponseMetadata(Type resourceClrType, bool endpointReturnsCollection) { Type documentOpenType = endpointReturnsCollection ? typeof(ResourceCollectionResponseDocument<>) : typeof(PrimaryResourceResponseDocument<>); - Type documentType = documentOpenType.MakeGenericType(primaryResourceType); + Type documentType = documentOpenType.MakeGenericType(resourceClrType); return new PrimaryResponseMetadata(documentType); } - private SecondaryResponseMetadata GetSecondaryResponseMetadata(Type primaryResourceType) - { - IDictionary responseTypesByRelationshipName = GetMetadataByRelationshipName(primaryResourceType, relationship => - { - Type documentType = relationship is HasManyAttribute - ? typeof(ResourceCollectionResponseDocument<>) - : typeof(SecondaryResourceResponseDocument<>); - - return documentType.MakeGenericType(relationship.RightType.ClrType); - }); - - return new SecondaryResponseMetadata(responseTypesByRelationshipName); - } - - private IDictionary GetMetadataByRelationshipName(Type primaryResourceType, - Func extractRelationshipMetadataCallback) + private SecondaryResponseMetadata GetSecondaryResponseMetadata(IEnumerable relationships) { - IReadOnlyCollection relationships = _resourceGraph.GetResourceType(primaryResourceType).Relationships; + IDictionary responseDocumentTypesByRelationshipName = relationships.ToDictionary(relationship => relationship.PublicName, + _nonPrimaryDocumentTypeFactory.GetForSecondaryResponse); - return relationships.ToDictionary(relationship => relationship.PublicName, extractRelationshipMetadataCallback); + return new SecondaryResponseMetadata(responseDocumentTypesByRelationshipName); } - private RelationshipResponseMetadata GetRelationshipResponseMetadata(Type primaryResourceType) + private RelationshipResponseMetadata GetRelationshipResponseMetadata(IEnumerable relationships) { - IDictionary responseTypesByRelationshipName = GetMetadataByRelationshipName(primaryResourceType, - relationship => relationship is HasManyAttribute - ? typeof(ResourceIdentifierCollectionResponseDocument<>).MakeGenericType(relationship.RightType.ClrType) - : typeof(ResourceIdentifierResponseDocument<>).MakeGenericType(relationship.RightType.ClrType)); + IDictionary responseDocumentTypesByRelationshipName = relationships.ToDictionary(relationship => relationship.PublicName, + _nonPrimaryDocumentTypeFactory.GetForRelationshipResponse); - return new RelationshipResponseMetadata(responseTypesByRelationshipName); + return new RelationshipResponseMetadata(responseDocumentTypesByRelationshipName); } } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/PrimaryRequestMetadata.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/PrimaryRequestMetadata.cs index ee86271274..876e04b913 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/PrimaryRequestMetadata.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/PrimaryRequestMetadata.cs @@ -4,13 +4,13 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata { internal sealed class PrimaryRequestMetadata : IJsonApiRequestMetadata { - public Type Type { get; } + public Type DocumentType { get; } - public PrimaryRequestMetadata(Type type) + public PrimaryRequestMetadata(Type documentType) { - ArgumentGuard.NotNull(type, nameof(type)); + ArgumentGuard.NotNull(documentType, nameof(documentType)); - Type = type; + DocumentType = documentType; } } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/PrimaryResponseMetadata.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/PrimaryResponseMetadata.cs index d6624e144e..81c7127c6f 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/PrimaryResponseMetadata.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/PrimaryResponseMetadata.cs @@ -4,13 +4,13 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata { internal sealed class PrimaryResponseMetadata : IJsonApiResponseMetadata { - public Type Type { get; } + public Type DocumentType { get; } - public PrimaryResponseMetadata(Type type) + public PrimaryResponseMetadata(Type documentType) { - ArgumentGuard.NotNull(type, nameof(type)); + ArgumentGuard.NotNull(documentType, nameof(documentType)); - Type = type; + DocumentType = documentType; } } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/RelationshipRequestMetadata.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/RelationshipRequestMetadata.cs index aea05eb5f1..00dacabbf8 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/RelationshipRequestMetadata.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/RelationshipRequestMetadata.cs @@ -5,13 +5,9 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata { internal sealed class RelationshipRequestMetadata : ExpansibleEndpointMetadata, IJsonApiRequestMetadata { - public override IDictionary ExpansionElements { get; } - - public RelationshipRequestMetadata(IDictionary requestBodyTypeByRelationshipName) + public RelationshipRequestMetadata(IDictionary documentTypesByRelationshipName) + : base(documentTypesByRelationshipName) { - ArgumentGuard.NotNull(requestBodyTypeByRelationshipName, nameof(requestBodyTypeByRelationshipName)); - - ExpansionElements = requestBodyTypeByRelationshipName; } } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/RelationshipResponseMetadata.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/RelationshipResponseMetadata.cs index 81aff140dc..35f56e06cd 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/RelationshipResponseMetadata.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/RelationshipResponseMetadata.cs @@ -5,13 +5,9 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata { internal sealed class RelationshipResponseMetadata : ExpansibleEndpointMetadata, IJsonApiResponseMetadata { - public override IDictionary ExpansionElements { get; } - - public RelationshipResponseMetadata(IDictionary responseTypesByRelationshipName) + public RelationshipResponseMetadata(IDictionary documentTypesByRelationshipName) + : base(documentTypesByRelationshipName) { - ArgumentGuard.NotNull(responseTypesByRelationshipName, nameof(responseTypesByRelationshipName)); - - ExpansionElements = responseTypesByRelationshipName; } } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/SecondaryResponseMetadata.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/SecondaryResponseMetadata.cs index c94b775485..5607a238e8 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/SecondaryResponseMetadata.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/SecondaryResponseMetadata.cs @@ -5,13 +5,9 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata { internal sealed class SecondaryResponseMetadata : ExpansibleEndpointMetadata, IJsonApiResponseMetadata { - public override IDictionary ExpansionElements { get; } - - public SecondaryResponseMetadata(IDictionary responseTypesByRelationshipName) + public SecondaryResponseMetadata(IDictionary documentTypesByRelationshipName) + : base(documentTypesByRelationshipName) { - ArgumentGuard.NotNull(responseTypesByRelationshipName, nameof(responseTypesByRelationshipName)); - - ExpansionElements = responseTypesByRelationshipName; } } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/NullableResourceIdentifierResponseDocument.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/NullableResourceIdentifierResponseDocument.cs new file mode 100644 index 0000000000..7f2673bf97 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/NullableResourceIdentifierResponseDocument.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.Links; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Documents +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal sealed class NullableResourceIdentifierResponseDocument : NullableSingleData> + where TResource : IIdentifiable + { + public IDictionary Meta { get; set; } = null!; + + public JsonapiObject Jsonapi { get; set; } = null!; + + [Required] + public LinksInResourceIdentifierDocument Links { get; set; } = null!; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/NullableSecondaryResourceResponseDocument.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/NullableSecondaryResourceResponseDocument.cs new file mode 100644 index 0000000000..4f86562323 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/NullableSecondaryResourceResponseDocument.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.Links; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Documents +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal sealed class NullableSecondaryResourceResponseDocument : NullableSingleData> + where TResource : IIdentifiable + { + public IDictionary Meta { get; set; } = null!; + + public JsonapiObject Jsonapi { get; set; } = null!; + + [Required] + public LinksInResourceDocument Links { get; set; } = null!; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/PrimaryResourceResponseDocument.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/PrimaryResourceResponseDocument.cs index 493a2cf445..3fea374319 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/PrimaryResourceResponseDocument.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/PrimaryResourceResponseDocument.cs @@ -7,6 +7,7 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Documents { + // Types in the current namespace are never touched by ASP.NET ModelState validation, therefore using a non-nullable reference type for a property does not imply this property is required. Instead, we use [Required] explicitly, because is how Swashbuckle is instructed to mark properties as required. [UsedImplicitly(ImplicitUseTargetFlags.Members)] internal sealed class PrimaryResourceResponseDocument : SingleData> where TResource : IIdentifiable diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/NonPrimaryDocumentTypeFactory.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/NonPrimaryDocumentTypeFactory.cs new file mode 100644 index 0000000000..fcf08df129 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/NonPrimaryDocumentTypeFactory.cs @@ -0,0 +1,73 @@ +using System; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.Documents; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.RelationshipData; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects +{ + internal sealed class NonPrimaryDocumentTypeFactory + { + private readonly DocumentOpenTypeOptions _secondaryResponseDocumentTypeOptions = new(typeof(ResourceCollectionResponseDocument<>), + typeof(NullableSecondaryResourceResponseDocument<>), typeof(SecondaryResourceResponseDocument<>)); + + private readonly DocumentOpenTypeOptions _relationshipRequestDocumentTypeOptions = new(typeof(ToManyRelationshipRequestData<>), + typeof(NullableToOneRelationshipRequestData<>), typeof(ToOneRelationshipRequestData<>)); + + private readonly DocumentOpenTypeOptions _relationshipResponseDocumentTypeOptions = new(typeof(ResourceIdentifierCollectionResponseDocument<>), + typeof(NullableResourceIdentifierResponseDocument<>), typeof(ResourceIdentifierResponseDocument<>)); + + public Type GetForSecondaryResponse(RelationshipAttribute relationship) + { + ArgumentGuard.NotNull(relationship, nameof(relationship)); + + return Get(relationship, _secondaryResponseDocumentTypeOptions); + } + + public Type GetForRelationshipRequest(RelationshipAttribute relationship) + { + ArgumentGuard.NotNull(relationship, nameof(relationship)); + + return Get(relationship, _relationshipRequestDocumentTypeOptions); + } + + public Type GetForRelationshipResponse(RelationshipAttribute relationship) + { + ArgumentGuard.NotNull(relationship, nameof(relationship)); + + return Get(relationship, _relationshipResponseDocumentTypeOptions); + } + + private static Type Get(RelationshipAttribute relationship, DocumentOpenTypeOptions typeOptions) + { + // @formatter:nested_ternary_style expanded + + Type documentOpenType = relationship is HasManyAttribute + ? typeOptions.ManyData + : relationship.IsNullable() + ? typeOptions.NullableSingleData + : typeOptions.SingleData; + + // @formatter:nested_ternary_style restore + + return documentOpenType.MakeGenericType(relationship.RightType.ClrType); + } + + private sealed class DocumentOpenTypeOptions + { + public Type ManyData { get; } + public Type NullableSingleData { get; } + public Type SingleData { get; } + + public DocumentOpenTypeOptions(Type manyDataOpenType, Type nullableSingleDataOpenType, Type singleDataOpenType) + { + ArgumentGuard.NotNull(manyDataOpenType, nameof(manyDataOpenType)); + ArgumentGuard.NotNull(nullableSingleDataOpenType, nameof(nullableSingleDataOpenType)); + ArgumentGuard.NotNull(singleDataOpenType, nameof(singleDataOpenType)); + + ManyData = manyDataOpenType; + NullableSingleData = nullableSingleDataOpenType; + SingleData = singleDataOpenType; + } + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/NullableSingleData.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/NullableSingleData.cs new file mode 100644 index 0000000000..685ca9eb72 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/NullableSingleData.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal abstract class NullableSingleData + where TData : ResourceIdentifierObject + { + [Required] + public TData? Data { get; set; } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/NullableToOneRelationshipRequestData.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/NullableToOneRelationshipRequestData.cs new file mode 100644 index 0000000000..a6163ab358 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/NullableToOneRelationshipRequestData.cs @@ -0,0 +1,12 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.RelationshipData +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal sealed class NullableToOneRelationshipRequestData : NullableSingleData> + where TResource : IIdentifiable + { + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/NullableToOneRelationshipResponseData.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/NullableToOneRelationshipResponseData.cs new file mode 100644 index 0000000000..7e2c8714ba --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/NullableToOneRelationshipResponseData.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.Links; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.RelationshipData +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal sealed class NullableToOneRelationshipResponseData : NullableSingleData> + where TResource : IIdentifiable + { + [Required] + public LinksInRelationshipObject Links { get; set; } = null!; + + public IDictionary Meta { get; set; } = null!; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipDataTypeFactory.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipDataTypeFactory.cs new file mode 100644 index 0000000000..d63d8f20c2 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipDataTypeFactory.cs @@ -0,0 +1,35 @@ +using System; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.RelationshipData; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects +{ + internal sealed class RelationshipDataTypeFactory + { + private readonly NonPrimaryDocumentTypeFactory _nonPrimaryDocumentTypeFactory = new(); + + public Type GetForRequest(RelationshipAttribute relationship) + { + ArgumentGuard.NotNull(relationship, nameof(relationship)); + + return _nonPrimaryDocumentTypeFactory.GetForRelationshipRequest(relationship); + } + + public Type GetForResponse(RelationshipAttribute relationship) + { + ArgumentGuard.NotNull(relationship, nameof(relationship)); + + // @formatter:nested_ternary_style expanded + + Type relationshipDataOpenType = relationship is HasManyAttribute + ? typeof(ToManyRelationshipResponseData<>) + : relationship.IsNullable() + ? typeof(NullableToOneRelationshipResponseData<>) + : typeof(ToOneRelationshipResponseData<>); + + // @formatter:nested_ternary_style restore + + return relationshipDataOpenType.MakeGenericType(relationship.RightType.ClrType); + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiOperationIdSelector.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiOperationIdSelector.cs index 07ce2fe7de..410a63a4ba 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiOperationIdSelector.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiOperationIdSelector.cs @@ -28,9 +28,12 @@ internal sealed class JsonApiOperationIdSelector [typeof(ResourcePatchRequestDocument<>)] = ResourceOperationIdTemplate, [typeof(void)] = ResourceOperationIdTemplate, [typeof(SecondaryResourceResponseDocument<>)] = SecondaryOperationIdTemplate, + [typeof(NullableSecondaryResourceResponseDocument<>)] = SecondaryOperationIdTemplate, [typeof(ResourceIdentifierCollectionResponseDocument<>)] = RelationshipOperationIdTemplate, [typeof(ResourceIdentifierResponseDocument<>)] = RelationshipOperationIdTemplate, + [typeof(NullableResourceIdentifierResponseDocument<>)] = RelationshipOperationIdTemplate, [typeof(ToOneRelationshipRequestData<>)] = RelationshipOperationIdTemplate, + [typeof(NullableToOneRelationshipRequestData<>)] = RelationshipOperationIdTemplate, [typeof(ToManyRelationshipRequestData<>)] = RelationshipOperationIdTemplate }; @@ -64,14 +67,19 @@ public string GetOperationId(ApiDescription endpoint) return ApplyTemplate(template, primaryResourceType.ClrType, endpoint); } - private static string GetTemplate(Type primaryResourceType, ApiDescription endpoint) + private static string GetTemplate(Type resourceClrType, ApiDescription endpoint) { - Type requestDocumentType = GetDocumentType(primaryResourceType, endpoint); + Type requestDocumentType = GetDocumentType(resourceClrType, endpoint); - return DocumentOpenTypeToOperationIdTemplateMap[requestDocumentType]; + if (!DocumentOpenTypeToOperationIdTemplateMap.TryGetValue(requestDocumentType, out string? template)) + { + throw new UnreachableCodeException(); + } + + return template; } - private static Type GetDocumentType(Type primaryResourceType, ApiDescription endpoint) + private static Type GetDocumentType(Type primaryResourceClrType, ApiDescription endpoint) { var producesResponseTypeAttribute = endpoint.ActionDescriptor.GetFilterMetadata(); @@ -89,7 +97,7 @@ private static Type GetDocumentType(Type primaryResourceType, ApiDescription end { Type documentResourceType = producesResponseTypeAttribute.Type.GetGenericArguments()[0]; - if (documentResourceType != primaryResourceType) + if (documentResourceType != primaryResourceClrType) { documentType = typeof(SecondaryResourceResponseDocument<>); } @@ -103,10 +111,10 @@ private static Type GetDocumentType(Type primaryResourceType, ApiDescription end return type.IsConstructedGenericType ? type.GetGenericTypeDefinition() : null; } - private string ApplyTemplate(string operationIdTemplate, Type primaryResourceType, ApiDescription endpoint) + private string ApplyTemplate(string operationIdTemplate, Type resourceClrType, ApiDescription endpoint) { string method = endpoint.HttpMethod!.ToLowerInvariant(); - string primaryResourceName = _formatter.FormatResourceName(primaryResourceType).Singularize(); + string primaryResourceName = _formatter.FormatResourceName(resourceClrType).Singularize(); string relationshipName = operationIdTemplate.Contains("[RelationshipName]") ? endpoint.RelativePath.Split("/").Last() : string.Empty; // @formatter:wrap_chained_method_calls chop_always diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiRequestFormatMetadataProvider.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiRequestFormatMetadataProvider.cs index 1fef9854f0..e3d2a0a17a 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiRequestFormatMetadataProvider.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiRequestFormatMetadataProvider.cs @@ -17,6 +17,7 @@ internal sealed class JsonApiRequestFormatMetadataProvider : IInputFormatter, IA { typeof(ToManyRelationshipRequestData<>), typeof(ToOneRelationshipRequestData<>), + typeof(NullableToOneRelationshipRequestData<>), typeof(ResourcePostRequestDocument<>), typeof(ResourcePatchRequestDocument<>) }; diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiSchemaIdSelector.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiSchemaIdSelector.cs index 352eba0584..d8ad776cf8 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiSchemaIdSelector.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiSchemaIdSelector.cs @@ -18,13 +18,17 @@ internal sealed class JsonApiSchemaIdSelector [typeof(ResourcePostRequestObject<>)] = "###-data-in-post-request", [typeof(ResourcePatchRequestObject<>)] = "###-data-in-patch-request", [typeof(ToOneRelationshipRequestData<>)] = "to-one-###-request-data", + [typeof(NullableToOneRelationshipRequestData<>)] = "nullable-to-one-###-request-data", [typeof(ToManyRelationshipRequestData<>)] = "to-many-###-request-data", [typeof(PrimaryResourceResponseDocument<>)] = "###-primary-response-document", [typeof(SecondaryResourceResponseDocument<>)] = "###-secondary-response-document", + [typeof(NullableSecondaryResourceResponseDocument<>)] = "nullable-###-secondary-response-document", [typeof(ResourceCollectionResponseDocument<>)] = "###-collection-response-document", [typeof(ResourceIdentifierResponseDocument<>)] = "###-identifier-response-document", + [typeof(NullableResourceIdentifierResponseDocument<>)] = "nullable-###-identifier-response-document", [typeof(ResourceIdentifierCollectionResponseDocument<>)] = "###-identifier-collection-response-document", [typeof(ToOneRelationshipResponseData<>)] = "to-one-###-response-data", + [typeof(NullableToOneRelationshipResponseData<>)] = "nullable-to-one-###-response-data", [typeof(ToManyRelationshipResponseData<>)] = "to-many-###-response-data", [typeof(ResourceResponseObject<>)] = "###-data-in-response", [typeof(ResourceIdentifierObject<>)] = "###-identifier" diff --git a/src/JsonApiDotNetCore.OpenApi/MemberInfoExtensions.cs b/src/JsonApiDotNetCore.OpenApi/MemberInfoExtensions.cs index 8d8c31c7ca..05a33e276c 100644 --- a/src/JsonApiDotNetCore.OpenApi/MemberInfoExtensions.cs +++ b/src/JsonApiDotNetCore.OpenApi/MemberInfoExtensions.cs @@ -1,97 +1,29 @@ using System; -using System.Collections.Generic; -using System.Linq; using System.Reflection; +using Swashbuckle.AspNetCore.SwaggerGen; namespace JsonApiDotNetCore.OpenApi { internal static class MemberInfoExtensions { - private const string NullableAttributeFullTypeName = "System.Runtime.CompilerServices.NullableAttribute"; - private const string NullableFlagsFieldName = "NullableFlags"; - private const string NullableContextAttributeFullTypeName = "System.Runtime.CompilerServices.NullableContextAttribute"; - private const string FlagFieldName = "Flag"; - - /// - /// Resolves the class of data type of the - /// - /// . - /// - public static DataTypeClass ResolveDataType(this MemberInfo memberInfo) + public static TypeCategory GetTypeCategory(this MemberInfo source) { - ArgumentGuard.NotNull(memberInfo, nameof(memberInfo)); - - // Based on https://github.com/domaindrivendev/Swashbuckle.AspNetCore/blob/67344fe0b7c7e78128159d8bf02ebfe91408c3da/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/MemberInfoExtensions.cs#L36 - - Type memberType = memberInfo.MemberType == MemberTypes.Field ? ((FieldInfo)memberInfo).FieldType : ((PropertyInfo)memberInfo).PropertyType; - - Type? underlyingType = Nullable.GetUnderlyingType(memberType); + ArgumentGuard.NotNull(source, nameof(source)); - if (underlyingType != null) + Type memberType = source.MemberType switch { - return underlyingType.IsValueType ? DataTypeClass.NullableValueType : DataTypeClass.NullableReferenceType; - } + MemberTypes.Field => ((FieldInfo)source).FieldType, + MemberTypes.Property => ((PropertyInfo)source).PropertyType, + _ => throw new ArgumentException("Cannot get the type category for members of type other than 'MemberTypes.Field' or 'MemberTypes.Property'.") + }; if (memberType.IsValueType) { - return DataTypeClass.ValueType; - } - - Attribute? nullableAttribute = GetNullableAttribute(memberInfo); - - if (nullableAttribute == null) - { - return HasNullableContextAttribute(memberInfo) ? DataTypeClass.NullableReferenceType : DataTypeClass.NonNullableReferenceType; - } - - return HasNullableFlag(nullableAttribute) ? DataTypeClass.NonNullableReferenceType : DataTypeClass.NullableReferenceType; - } - - private static Attribute? GetNullableAttribute(MemberInfo memberInfo) - { - Attribute? nullableAttribute = memberInfo.GetCustomAttributes() - .FirstOrDefault(attr => string.Equals(attr.GetType().FullName, NullableAttributeFullTypeName)); - - return nullableAttribute; - } - - private static bool HasNullableContextAttribute(MemberInfo memberInfo) - { - if (memberInfo.DeclaringType?.DeclaringType == null) - { - return false; - } - - Type[] declaringTypes = memberInfo.DeclaringType is { IsNested: true } - ? new[] - { - memberInfo.DeclaringType, - memberInfo.DeclaringType.DeclaringType - } - : new[] - { - memberInfo.DeclaringType - }; - - foreach (Type type in declaringTypes) - { - var attributes = (IEnumerable)type.GetCustomAttributes(false); - - object? nullableContext = attributes.FirstOrDefault(attr => string.Equals(attr.GetType().FullName, NullableContextAttributeFullTypeName)); - - if (nullableContext != null) - { - return nullableContext.GetType().GetField(FlagFieldName) is { } field && field.GetValue(nullableContext) is byte and 1; - } + return Nullable.GetUnderlyingType(memberType) != null ? TypeCategory.NullableValueType : TypeCategory.ValueType; } - return false; - } - - private static bool HasNullableFlag(Attribute nullableAttribute) - { - FieldInfo? fieldInfo = nullableAttribute.GetType().GetField(NullableFlagsFieldName); - return fieldInfo is { } nullableFlagsField && nullableFlagsField.GetValue(nullableAttribute) is byte[] { Length: >= 1 } flags && flags[0] == 1; + // 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/OpenApiEndpointConvention.cs b/src/JsonApiDotNetCore.OpenApi/OpenApiEndpointConvention.cs index 7bc0178df1..c4c5dfb7e9 100644 --- a/src/JsonApiDotNetCore.OpenApi/OpenApiEndpointConvention.cs +++ b/src/JsonApiDotNetCore.OpenApi/OpenApiEndpointConvention.cs @@ -16,16 +16,13 @@ namespace JsonApiDotNetCore.OpenApi /// internal sealed class OpenApiEndpointConvention : IActionModelConvention { - private readonly IResourceGraph _resourceGraph; private readonly IControllerResourceMapping _controllerResourceMapping; private readonly EndpointResolver _endpointResolver = new(); - public OpenApiEndpointConvention(IResourceGraph resourceGraph, IControllerResourceMapping controllerResourceMapping) + public OpenApiEndpointConvention(IControllerResourceMapping controllerResourceMapping) { - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); ArgumentGuard.NotNull(controllerResourceMapping, nameof(controllerResourceMapping)); - _resourceGraph = resourceGraph; _controllerResourceMapping = controllerResourceMapping; } @@ -69,9 +66,12 @@ private bool ShouldSuppressEndpoint(JsonApiEndpoint endpoint, Type controllerTyp private IReadOnlyCollection GetRelationshipsOfPrimaryResource(Type controllerType) { - ResourceType primaryResourceTypeOfEndpoint = _controllerResourceMapping.GetResourceTypeForController(controllerType)!; + ResourceType? primaryResourceType = _controllerResourceMapping.GetResourceTypeForController(controllerType); - ResourceType primaryResourceType = _resourceGraph.GetResourceType(primaryResourceTypeOfEndpoint.ClrType); + if (primaryResourceType == null) + { + throw new UnreachableCodeException(); + } return primaryResourceType.Relationships; } diff --git a/src/JsonApiDotNetCore.OpenApi/ResourceFieldAttributeExtensions.cs b/src/JsonApiDotNetCore.OpenApi/ResourceFieldAttributeExtensions.cs new file mode 100644 index 0000000000..d59b3a891e --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/ResourceFieldAttributeExtensions.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; +using JsonApiDotNetCore.Resources.Annotations; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi +{ + internal static class ResourceFieldAttributeExtensions + { + public static bool IsNullable(this ResourceFieldAttribute source) + { + TypeCategory fieldTypeCategory = source.Property.GetTypeCategory(); + bool hasRequiredAttribute = source.Property.HasAttribute(); + + return fieldTypeCategory switch + { + TypeCategory.NonNullableReferenceType or TypeCategory.ValueType => false, + TypeCategory.NullableReferenceType or TypeCategory.NullableValueType => !hasRequiredAttribute, + _ => throw new UnreachableCodeException() + }; + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/ServiceCollectionExtensions.cs b/src/JsonApiDotNetCore.OpenApi/ServiceCollectionExtensions.cs index 0cf26b312a..8f2a781fbd 100644 --- a/src/JsonApiDotNetCore.OpenApi/ServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore.OpenApi/ServiceCollectionExtensions.cs @@ -38,13 +38,12 @@ private static void AddCustomApiExplorer(IServiceCollection services, IMvcCoreBu { services.AddSingleton(provider => { - var resourceGraph = provider.GetRequiredService(); var controllerResourceMapping = provider.GetRequiredService(); var actionDescriptorCollectionProvider = provider.GetRequiredService(); var apiDescriptionProviders = provider.GetRequiredService>(); JsonApiActionDescriptorCollectionProvider descriptorCollectionProviderWrapper = - new(resourceGraph, controllerResourceMapping, actionDescriptorCollectionProvider); + new(controllerResourceMapping, actionDescriptorCollectionProvider); return new ApiDescriptionGroupCollectionProvider(descriptorCollectionProviderWrapper, apiDescriptionProviders); }); @@ -130,10 +129,9 @@ private static void AddSwashbuckleCliCompatibility(IServiceScope scope, IMvcCore private static void AddOpenApiEndpointConvention(IServiceScope scope, IMvcCoreBuilder mvcBuilder) { - var resourceGraph = scope.ServiceProvider.GetRequiredService(); var controllerResourceMapping = scope.ServiceProvider.GetRequiredService(); - mvcBuilder.AddMvcOptions(options => options.Conventions.Add(new OpenApiEndpointConvention(resourceGraph, controllerResourceMapping))); + mvcBuilder.AddMvcOptions(options => options.Conventions.Add(new OpenApiEndpointConvention(controllerResourceMapping))); } } } diff --git a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/JsonApiSchemaGenerator.cs b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/JsonApiSchemaGenerator.cs index 0a7c97f62f..c22cb0b96c 100644 --- a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/JsonApiSchemaGenerator.cs +++ b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/JsonApiSchemaGenerator.cs @@ -12,20 +12,20 @@ namespace JsonApiDotNetCore.OpenApi.SwaggerComponents { internal sealed class JsonApiSchemaGenerator : ISchemaGenerator { - private static readonly Type[] JsonApiResourceDocumentOpenTypes = + private static readonly Type[] JsonApiDocumentOpenTypes = { typeof(ResourceCollectionResponseDocument<>), typeof(PrimaryResourceResponseDocument<>), typeof(SecondaryResourceResponseDocument<>), + typeof(NullableSecondaryResourceResponseDocument<>), typeof(ResourcePostRequestDocument<>), - typeof(ResourcePatchRequestDocument<>) - }; - - private static readonly Type[] SingleNonPrimaryDataDocumentOpenTypes = - { - typeof(ToOneRelationshipRequestData<>), + typeof(ResourcePatchRequestDocument<>), + typeof(ResourceIdentifierCollectionResponseDocument<>), typeof(ResourceIdentifierResponseDocument<>), - typeof(SecondaryResourceResponseDocument<>) + typeof(NullableResourceIdentifierResponseDocument<>), + typeof(ToManyRelationshipRequestData<>), + typeof(ToOneRelationshipRequestData<>), + typeof(NullableToOneRelationshipRequestData<>) }; private readonly ISchemaGenerator _defaultSchemaGenerator; @@ -56,51 +56,60 @@ public OpenApiSchema GenerateSchema(Type type, SchemaRepository schemaRepository return jsonApiDocumentSchema; } - OpenApiSchema schema = IsJsonApiResourceDocument(type) - ? GenerateResourceJsonApiDocumentSchema(type) - : _defaultSchemaGenerator.GenerateSchema(type, schemaRepository, memberInfo, parameterInfo); - - if (IsSingleNonPrimaryDataDocument(type)) + if (IsJsonApiDocument(type)) { - SetDataObjectSchemaToNullable(schema); + OpenApiSchema schema = GenerateJsonApiDocumentSchema(type); + + if (IsDataPropertyNullable(type)) + { + SetDataObjectSchemaToNullable(schema); + } } - return schema; + return _defaultSchemaGenerator.GenerateSchema(type, schemaRepository, memberInfo, parameterInfo); } - private static bool IsJsonApiResourceDocument(Type type) + private static bool IsJsonApiDocument(Type type) { - return type.IsConstructedGenericType && JsonApiResourceDocumentOpenTypes.Contains(type.GetGenericTypeDefinition()); + return type.IsConstructedGenericType && JsonApiDocumentOpenTypes.Contains(type.GetGenericTypeDefinition()); } - private OpenApiSchema GenerateResourceJsonApiDocumentSchema(Type type) + private OpenApiSchema GenerateJsonApiDocumentSchema(Type documentType) { - Type resourceObjectType = type.BaseType!.GenericTypeArguments[0]; + Type resourceObjectType = documentType.BaseType!.GenericTypeArguments[0]; if (!_schemaRepositoryAccessor.Current.TryLookupByType(resourceObjectType, out OpenApiSchema referenceSchemaForResourceObject)) { referenceSchemaForResourceObject = _resourceObjectSchemaGenerator.GenerateSchema(resourceObjectType); } - OpenApiSchema referenceSchemaForDocument = _defaultSchemaGenerator.GenerateSchema(type, _schemaRepositoryAccessor.Current); + OpenApiSchema referenceSchemaForDocument = _defaultSchemaGenerator.GenerateSchema(documentType, _schemaRepositoryAccessor.Current); OpenApiSchema fullSchemaForDocument = _schemaRepositoryAccessor.Current.Schemas[referenceSchemaForDocument.Reference.Id]; - OpenApiSchema referenceSchemaForDataObject = - IsSingleDataDocument(type) ? referenceSchemaForResourceObject : CreateArrayTypeDataSchema(referenceSchemaForResourceObject); + OpenApiSchema referenceSchemaForDataObject = IsManyDataDocument(documentType) + ? CreateArrayTypeDataSchema(referenceSchemaForResourceObject) + : referenceSchemaForResourceObject; fullSchemaForDocument.Properties[JsonApiObjectPropertyName.Data] = referenceSchemaForDataObject; return referenceSchemaForDocument; } - private static bool IsSingleDataDocument(Type type) + private static bool IsManyDataDocument(Type documentType) { - return type.BaseType?.IsConstructedGenericType == true && type.BaseType.GetGenericTypeDefinition() == typeof(SingleData<>); + return documentType.BaseType!.GetGenericTypeDefinition() == typeof(ManyData<>); } - private static bool IsSingleNonPrimaryDataDocument(Type type) + private static bool IsDataPropertyNullable(Type type) { - return type.IsConstructedGenericType && SingleNonPrimaryDataDocumentOpenTypes.Contains(type.GetGenericTypeDefinition()); + PropertyInfo? dataProperty = type.GetProperty(nameof(JsonApiObjectPropertyName.Data)); + + if (dataProperty == null) + { + throw new UnreachableCodeException(); + } + + return dataProperty.GetTypeCategory() == TypeCategory.NullableReferenceType; } private void SetDataObjectSchemaToNullable(OpenApiSchema referenceSchemaForDocument) diff --git a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceFieldObjectSchemaBuilder.cs b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceFieldObjectSchemaBuilder.cs index a0e673fb2c..dec74acd6f 100644 --- a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceFieldObjectSchemaBuilder.cs +++ b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceFieldObjectSchemaBuilder.cs @@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using System.Reflection; +using JsonApiDotNetCore.OpenApi.JsonApiObjects; using JsonApiDotNetCore.OpenApi.JsonApiObjects.RelationshipData; using JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects; using JsonApiDotNetCore.Resources.Annotations; @@ -15,6 +16,13 @@ internal sealed class ResourceFieldObjectSchemaBuilder { private static readonly SchemaRepository ResourceSchemaRepository = new(); + private static readonly Type[] RelationshipResponseDataOpenTypes = + { + typeof(ToOneRelationshipResponseData<>), + typeof(ToManyRelationshipResponseData<>), + typeof(NullableToOneRelationshipResponseData<>) + }; + private readonly ResourceTypeInfo _resourceTypeInfo; private readonly ISchemaRepositoryAccessor _schemaRepositoryAccessor; private readonly SchemaGenerator _defaultSchemaGenerator; @@ -22,6 +30,7 @@ internal sealed class ResourceFieldObjectSchemaBuilder private readonly ResourceTypeSchemaGenerator _resourceTypeSchemaGenerator; private readonly NullableReferenceSchemaGenerator _nullableReferenceSchemaGenerator; private readonly IDictionary _schemasForResourceFields; + private readonly RelationshipDataTypeFactory _relationshipDataTypeFactory = new(); public ResourceFieldObjectSchemaBuilder(ResourceTypeInfo resourceTypeInfo, ISchemaRepositoryAccessor schemaRepositoryAccessor, SchemaGenerator defaultSchemaGenerator, JsonApiSchemaIdSelector jsonApiSchemaIdSelector, ResourceTypeSchemaGenerator resourceTypeSchemaGenerator) @@ -83,17 +92,11 @@ private void SetMembersOfAttributesObject(OpenApiSchema fullSchemaForAttributesO { AddAttributeSchemaToResourceObject(matchingAttribute, fullSchemaForAttributesObject, resourceFieldSchema); - DataTypeClass propertyDataTypeClass = matchingAttribute.Property.ResolveDataType(); - bool hasRequiredAttribute = matchingAttribute.Property.GetCustomAttribute() != null; - - resourceFieldSchema.Nullable = IsAttributeNullable(propertyDataTypeClass, hasRequiredAttribute); + resourceFieldSchema.Nullable = matchingAttribute.IsNullable(); - if (_resourceTypeInfo.ResourceObjectOpenType == typeof(ResourcePostRequestObject<>)) + if (IsFieldRequired(matchingAttribute)) { - if (IsAttributeRequired(propertyDataTypeClass, hasRequiredAttribute)) - { - fullSchemaForAttributesObject.Required.Add(matchingAttribute.PublicName); - } + fullSchemaForAttributesObject.Required.Add(matchingAttribute.PublicName); } } } @@ -116,34 +119,32 @@ private void AddAttributeSchemaToResourceObject(AttrAttribute attribute, OpenApi attributesObjectSchema.Properties.Add(attribute.PublicName, resourceAttributeSchema); } - private static bool IsAttributeNullable(DataTypeClass dataTypeClass, bool hasRequiredAttribute) + private void ExposeSchema(OpenApiReference openApiReference, Type typeRepresentedBySchema) { - return dataTypeClass switch - { - DataTypeClass.NonNullableReferenceType or DataTypeClass.ValueType => false, - DataTypeClass.NullableReferenceType or DataTypeClass.NullableValueType => !hasRequiredAttribute, - _ => throw new UnreachableCodeException() - }; + OpenApiSchema fullSchema = ResourceSchemaRepository.Schemas[openApiReference.Id]; + _schemaRepositoryAccessor.Current.AddDefinition(openApiReference.Id, fullSchema); + _schemaRepositoryAccessor.Current.RegisterType(typeRepresentedBySchema, openApiReference.Id); } - private static bool IsAttributeRequired(DataTypeClass dataTypeClass, bool hasRequiredAttribute) + private bool IsFieldRequired(ResourceFieldAttribute field) { - return dataTypeClass switch + if (field is HasManyAttribute || _resourceTypeInfo.ResourceObjectOpenType != typeof(ResourcePostRequestObject<>)) { - DataTypeClass.NonNullableReferenceType => true, - DataTypeClass.ValueType => hasRequiredAttribute, - DataTypeClass.NullableReferenceType or DataTypeClass.NullableValueType => hasRequiredAttribute, + return false; + } + + TypeCategory fieldTypeCategory = field.Property.GetTypeCategory(); + bool hasRequiredAttribute = field.Property.HasAttribute(); + + return fieldTypeCategory switch + { + TypeCategory.NonNullableReferenceType => true, + TypeCategory.ValueType => hasRequiredAttribute, + TypeCategory.NullableReferenceType or TypeCategory.NullableValueType => hasRequiredAttribute, _ => throw new UnreachableCodeException() }; } - private void ExposeSchema(OpenApiReference openApiReference, Type typeRepresentedBySchema) - { - OpenApiSchema fullSchema = ResourceSchemaRepository.Schemas[openApiReference.Id]; - _schemaRepositoryAccessor.Current.AddDefinition(openApiReference.Id, fullSchema); - _schemaRepositoryAccessor.Current.RegisterType(typeRepresentedBySchema, openApiReference.Id); - } - private OpenApiSchema GetReferenceSchemaForFieldObject(OpenApiSchema fullSchema, string fieldObjectName) { // NSwag does not have proper support for using an inline schema for the attributes and relationships object in a resource object, see https://github.com/RicoSuter/NSwag/issues/3474. Once this issue has been resolved, we can remove this. @@ -212,28 +213,26 @@ private void GenerateResourceIdentifierObjectSchema(Type resourceIdentifierObjec fullSchemaForResourceIdentifierObject.Properties[JsonApiObjectPropertyName.Type] = _resourceTypeSchemaGenerator.Get(resourceType); } - private void AddRelationshipDataSchemaToResourceObject(RelationshipAttribute relationship, OpenApiSchema relationshipObjectSchema) + private void AddRelationshipDataSchemaToResourceObject(RelationshipAttribute relationship, OpenApiSchema fullSchemaForRelationshipObject) { Type relationshipDataType = GetRelationshipDataType(relationship, _resourceTypeInfo.ResourceObjectOpenType); - OpenApiSchema referenceSchemaForRelationshipData = GetReferenceSchemaForRelationshipData(relationshipDataType) ?? - CreateRelationshipDataObjectSchema(relationship, relationshipDataType); + OpenApiSchema relationshipDataSchema = GetReferenceSchemaForRelationshipData(relationshipDataType) ?? + CreateRelationshipDataObjectSchema(relationshipDataType); - relationshipObjectSchema.Properties.Add(relationship.PublicName, referenceSchemaForRelationshipData); - } + fullSchemaForRelationshipObject.Properties.Add(relationship.PublicName, relationshipDataSchema); - private static Type GetRelationshipDataType(RelationshipAttribute relationship, Type resourceObjectType) - { - if (resourceObjectType.GetGenericTypeDefinition().IsAssignableTo(typeof(ResourceResponseObject<>))) + if (IsFieldRequired(relationship)) { - return relationship is HasOneAttribute - ? typeof(ToOneRelationshipResponseData<>).MakeGenericType(relationship.RightType.ClrType) - : typeof(ToManyRelationshipResponseData<>).MakeGenericType(relationship.RightType.ClrType); + fullSchemaForRelationshipObject.Required.Add(relationship.PublicName); } + } - return relationship is HasOneAttribute - ? typeof(ToOneRelationshipRequestData<>).MakeGenericType(relationship.RightType.ClrType) - : typeof(ToManyRelationshipRequestData<>).MakeGenericType(relationship.RightType.ClrType); + private Type GetRelationshipDataType(RelationshipAttribute relationship, Type resourceObjectType) + { + return resourceObjectType.GetGenericTypeDefinition().IsAssignableTo(typeof(ResourceResponseObject<>)) + ? _relationshipDataTypeFactory.GetForResponse(relationship) + : _relationshipDataTypeFactory.GetForRequest(relationship); } private OpenApiSchema? GetReferenceSchemaForRelationshipData(Type relationshipDataType) @@ -242,26 +241,43 @@ private static Type GetRelationshipDataType(RelationshipAttribute relationship, return referenceSchemaForRelationshipData; } - private OpenApiSchema CreateRelationshipDataObjectSchema(RelationshipAttribute relationship, Type relationshipDataType) + private OpenApiSchema CreateRelationshipDataObjectSchema(Type relationshipDataType) { OpenApiSchema referenceSchema = _defaultSchemaGenerator.GenerateSchema(relationshipDataType, _schemaRepositoryAccessor.Current); OpenApiSchema fullSchema = _schemaRepositoryAccessor.Current.Schemas[referenceSchema.Reference.Id]; + if (IsDataPropertyNullable(relationshipDataType)) + { + fullSchema.Properties[JsonApiObjectPropertyName.Data] = + _nullableReferenceSchemaGenerator.GenerateSchema(fullSchema.Properties[JsonApiObjectPropertyName.Data]); + } + Type relationshipDataOpenType = relationshipDataType.GetGenericTypeDefinition(); - if (relationshipDataOpenType == typeof(ToOneRelationshipResponseData<>) || relationshipDataOpenType == typeof(ToManyRelationshipResponseData<>)) + if (IsRelationshipDataPropertyInResponse(relationshipDataOpenType)) { fullSchema.Required.Remove(JsonApiObjectPropertyName.Data); } - if (relationship is HasOneAttribute) + return referenceSchema; + } + + private static bool IsRelationshipDataPropertyInResponse(Type relationshipDataOpenType) + { + return RelationshipResponseDataOpenTypes.Contains(relationshipDataOpenType); + } + + private static bool IsDataPropertyNullable(Type type) + { + PropertyInfo? dataProperty = type.GetProperty(nameof(JsonApiObjectPropertyName.Data)); + + if (dataProperty == null) { - fullSchema.Properties[JsonApiObjectPropertyName.Data] = - _nullableReferenceSchemaGenerator.GenerateSchema(fullSchema.Properties[JsonApiObjectPropertyName.Data]); + throw new UnreachableCodeException(); } - return referenceSchema; + return dataProperty.GetTypeCategory() == TypeCategory.NullableReferenceType; } } } diff --git a/src/JsonApiDotNetCore.OpenApi/DataTypeClass.cs b/src/JsonApiDotNetCore.OpenApi/TypeCategory.cs similarity index 83% rename from src/JsonApiDotNetCore.OpenApi/DataTypeClass.cs rename to src/JsonApiDotNetCore.OpenApi/TypeCategory.cs index bd114c4f45..bb8104d6fb 100644 --- a/src/JsonApiDotNetCore.OpenApi/DataTypeClass.cs +++ b/src/JsonApiDotNetCore.OpenApi/TypeCategory.cs @@ -1,9 +1,9 @@ namespace JsonApiDotNetCore.OpenApi { - internal enum DataTypeClass + internal enum TypeCategory { - NullableReferenceType, NonNullableReferenceType, + NullableReferenceType, ValueType, NullableValueType } diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index 8809659e65..4867102d63 100644 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -7,7 +7,7 @@ jsonapidotnetcore;jsonapi;json:api;dotnet;asp.net - A framework for building JSON:API compliant REST APIs using .NET Core and Entity Framework Core. Includes support for Atomic Operations. The ultimate goal of this library is to eliminate as much boilerplate as possible by offering out-of-the-box features such as sorting, filtering and pagination. You just need to focus on defining the resources and implementing your custom business logic. This library has been designed around dependency injection making extensibility incredibly easy. + A framework for building JSON:API compliant REST APIs using ASP.NET and Entity Framework Core. Includes support for Atomic Operations. The ultimate goal of this library is to eliminate as much boilerplate as possible by offering out-of-the-box features such as sorting, filtering and pagination. You just need to focus on defining the resources and implementing your custom business logic. This library has been designed around dependency injection making extensibility incredibly easy. json-api-dotnet https://www.jsonapi.net/ MIT diff --git a/test/OpenApiClientTests/LegacyClient/RequestTests.cs b/test/OpenApiClientTests/LegacyClient/RequestTests.cs index 1935246627..4f4ddce61b 100644 --- a/test/OpenApiClientTests/LegacyClient/RequestTests.cs +++ b/test/OpenApiClientTests/LegacyClient/RequestTests.cs @@ -69,7 +69,22 @@ public async Task Partial_posting_resource_with_selected_relationships_produces_ Type = FlightsResourceType.Flights, Relationships = new FlightRelationshipsInPostRequest { - Purser = new ToOneFlightAttendantRequestData() + Purser = new ToOneFlightAttendantRequestData + { + Data = new FlightAttendantIdentifier + { + Id = "bBJHu", + Type = FlightAttendantsResourceType.FlightAttendants + } + }, + BackupPurser = new NullableToOneFlightAttendantRequestData + { + Data = new FlightAttendantIdentifier + { + Id = "NInmX", + Type = FlightAttendantsResourceType.FlightAttendants + } + } } } }; @@ -91,7 +106,16 @@ public async Task Partial_posting_resource_with_selected_relationships_produces_ ""type"": ""flights"", ""relationships"": { ""purser"": { - ""data"": null + ""data"": { + ""type"": ""flight-attendants"", + ""id"": ""bBJHu"" + } + }, + ""backup-purser"": { + ""data"": { + ""type"": ""flight-attendants"", + ""id"": ""NInmX"" + } } } } diff --git a/test/OpenApiClientTests/LegacyClient/ResponseTests.cs b/test/OpenApiClientTests/LegacyClient/ResponseTests.cs index 91b6c65d7b..5a823219aa 100644 --- a/test/OpenApiClientTests/LegacyClient/ResponseTests.cs +++ b/test/OpenApiClientTests/LegacyClient/ResponseTests.cs @@ -241,7 +241,10 @@ public async Task Posting_resource_translates_response() ""self"": """ + HostPrefix + @"flights/" + flightId + @"/relationships/purser"", ""related"": """ + HostPrefix + @"flights/" + flightId + @"/purser"" }, - ""data"": null + ""data"": { + ""type"": ""flight-attendants"", + ""id"": """ + flightAttendantId + @""" + } }, ""cabin-crew-members"": { ""links"": { @@ -284,7 +287,7 @@ public async Task Posting_resource_translates_response() { Data = new FlightAttendantIdentifier { - Id = "XxuIu", + Id = flightAttendantId, Type = FlightAttendantsResourceType.FlightAttendants } } @@ -294,7 +297,8 @@ public async Task Posting_resource_translates_response() // Assert document.Data.Attributes.Should().BeNull(); - document.Data.Relationships.Purser.Data.Should().BeNull(); + document.Data.Relationships.Purser.Data.Should().NotBeNull(); + document.Data.Relationships.Purser.Data.Id.Should().Be(flightAttendantId); document.Data.Relationships.CabinCrewMembers.Data.Should().HaveCount(1); document.Data.Relationships.CabinCrewMembers.Data.First().Id.Should().Be(flightAttendantId); document.Data.Relationships.CabinCrewMembers.Data.First().Type.Should().Be(FlightAttendantsResourceType.FlightAttendants); @@ -381,6 +385,11 @@ public async Task Getting_secondary_resource_translates_response() { // Arrange const string flightId = "ZvuH1"; + const string purserId = "bBJHu"; + const string emailAddress = "email@example.com"; + const string age = "20"; + const string profileImageUrl = "www.image.com"; + const string distanceTraveledInKilometer = "5000"; const string responseBody = @"{ ""links"": { @@ -388,7 +397,33 @@ public async Task Getting_secondary_resource_translates_response() ""first"": """ + HostPrefix + @"flights/" + flightId + @"/purser"", ""last"": """ + HostPrefix + @"flights/" + flightId + @"/purser"" }, - ""data"": null + ""data"": { + ""type"": ""flight-attendants"", + ""id"": """ + purserId + @""", + ""attributes"": { + ""email-address"": """ + emailAddress + @""", + ""age"": """ + age + @""", + ""profile-image-url"": """ + profileImageUrl + @""", + ""distance-traveled-in-kilometers"": """ + distanceTraveledInKilometer + @""", + }, + ""relationships"": { + ""scheduled-for-flights"": { + ""links"": { + ""self"": """ + HostPrefix + @"flight-attendants/" + purserId + @"/relationships/scheduled-for-flights"", + ""related"": """ + HostPrefix + @"flight-attendants/" + purserId + @"/scheduled-for-flights"" + } + }, + ""purser-on-flights"": { + ""links"": { + ""self"": """ + HostPrefix + @"flight-attendants/" + purserId + @"/relationships/purser-on-flights"", + ""related"": """ + HostPrefix + @"flight-attendants/" + purserId + @"/purser-on-flights"" + } + }, + }, + ""links"": { + ""self"": """ + HostPrefix + @"flight-attendants/" + purserId + @""", + } + } }"; using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.OK, responseBody); @@ -397,6 +432,36 @@ public async Task Getting_secondary_resource_translates_response() // Act FlightAttendantSecondaryResponseDocument document = await apiClient.GetFlightPurserAsync(flightId); + // Assert + document.Data.Should().NotBeNull(); + document.Data.Id.Should().Be(purserId); + document.Data.Attributes.EmailAddress.Should().Be(emailAddress); + document.Data.Attributes.Age.Should().Be(int.Parse(age)); + document.Data.Attributes.ProfileImageUrl.Should().Be(profileImageUrl); + document.Data.Attributes.DistanceTraveledInKilometers.Should().Be(int.Parse(distanceTraveledInKilometer)); + } + + [Fact] + public async Task Getting_nullable_secondary_resource_translates_response() + { + // Arrange + const string flightId = "ZvuH1"; + + const string responseBody = @"{ + ""links"": { + ""self"": """ + HostPrefix + @"flights/" + flightId + @"/backup-purser"", + ""first"": """ + HostPrefix + @"flights/" + flightId + @"/backup-purser"", + ""last"": """ + HostPrefix + @"flights/" + flightId + @"/backup-purser"" + }, + ""data"": null +}"; + + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.OK, responseBody); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + // Act + NullableFlightAttendantSecondaryResponseDocument document = await apiClient.GetFlightBackupPurserAsync(flightId); + // Assert document.Data.Should().BeNull(); } @@ -425,6 +490,30 @@ public async Task Getting_secondary_resources_translates_response() document.Data.Should().BeEmpty(); } + [Fact] + public async Task Getting_nullable_ToOne_relationship_translates_response() + { + // Arrange + const string flightId = "ZvuH1"; + + const string responseBody = @"{ + ""links"": { + ""self"": """ + HostPrefix + @"flights/" + flightId + @"/relationships/backup-purser"", + ""related"": """ + HostPrefix + @"flights/" + flightId + @"/relationships/backup-purser"" + }, + ""data"": null +}"; + + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.OK, responseBody); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + // Act + NullableFlightAttendantIdentifierResponseDocument document = await apiClient.GetFlightBackupPurserRelationshipAsync(flightId); + + // Assert + document.Data.Should().BeNull(); + } + [Fact] public async Task Getting_ToOne_relationship_translates_response() { diff --git a/test/OpenApiTests/LegacyOpenApiIntegration/Flight.cs b/test/OpenApiTests/LegacyOpenApiIntegration/Flight.cs index c5a26a34c5..d5608d45ce 100644 --- a/test/OpenApiTests/LegacyOpenApiIntegration/Flight.cs +++ b/test/OpenApiTests/LegacyOpenApiIntegration/Flight.cs @@ -34,9 +34,12 @@ public sealed class Flight : Identifiable [HasOne] public FlightAttendant Purser { get; set; } = null!; + [HasOne] + public FlightAttendant? BackupPurser { get; set; } + [Attr] [NotMapped] - public ICollection ServicesOnBoard { get; set; } = null!; + public ICollection ServicesOnBoard { get; set; } = new HashSet(); [HasMany] public ICollection Passengers { get; set; } = new HashSet(); diff --git a/test/OpenApiTests/LegacyOpenApiIntegration/LegacyIntegrationDbContext.cs b/test/OpenApiTests/LegacyOpenApiIntegration/LegacyIntegrationDbContext.cs index 6b9d861327..796dc7394a 100644 --- a/test/OpenApiTests/LegacyOpenApiIntegration/LegacyIntegrationDbContext.cs +++ b/test/OpenApiTests/LegacyOpenApiIntegration/LegacyIntegrationDbContext.cs @@ -27,6 +27,9 @@ protected override void OnModelCreating(ModelBuilder builder) builder.Entity() .HasOne(flight => flight.Purser) .WithMany(flightAttendant => flightAttendant.PurserOnFlights); + + builder.Entity() + .HasOne(flight => flight.BackupPurser); } } } diff --git a/test/OpenApiTests/LegacyOpenApiIntegration/swagger.json b/test/OpenApiTests/LegacyOpenApiIntegration/swagger.json index eff75497b6..00d523bfe8 100644 --- a/test/OpenApiTests/LegacyOpenApiIntegration/swagger.json +++ b/test/OpenApiTests/LegacyOpenApiIntegration/swagger.json @@ -1184,6 +1184,152 @@ } } }, + "/api/v1/flights/{id}/backup-purser": { + "get": { + "tags": [ + "flights" + ], + "operationId": "get-flight-backup-purser", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/nullable-flight-attendant-secondary-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "flights" + ], + "operationId": "head-flight-backup-purser", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/nullable-flight-attendant-secondary-response-document" + } + } + } + } + } + } + }, + "/api/v1/flights/{id}/relationships/backup-purser": { + "get": { + "tags": [ + "flights" + ], + "operationId": "get-flight-backup-purser-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/nullable-flight-attendant-identifier-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "flights" + ], + "operationId": "head-flight-backup-purser-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/nullable-flight-attendant-identifier-response-document" + } + } + } + } + } + }, + "patch": { + "tags": [ + "flights" + ], + "operationId": "patch-flight-backup-purser-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/nullable-to-one-flight-attendant-request-data" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + } + } + } + }, "/api/v1/flights/{id}/cabin-crew-members": { "get": { "tags": [ @@ -2257,14 +2403,7 @@ "type": "object", "properties": { "data": { - "oneOf": [ - { - "$ref": "#/components/schemas/flight-attendant-identifier" - }, - { - "$ref": "#/components/schemas/null-value" - } - ] + "$ref": "#/components/schemas/flight-attendant-identifier" }, "meta": { "type": "object", @@ -2370,14 +2509,7 @@ "type": "object", "properties": { "data": { - "oneOf": [ - { - "$ref": "#/components/schemas/flight-attendant-data-in-response" - }, - { - "$ref": "#/components/schemas/null-value" - } - ] + "$ref": "#/components/schemas/flight-attendant-data-in-response" }, "meta": { "type": "object", @@ -2641,6 +2773,9 @@ "purser": { "$ref": "#/components/schemas/to-one-flight-attendant-request-data" }, + "backup-purser": { + "$ref": "#/components/schemas/nullable-to-one-flight-attendant-request-data" + }, "passengers": { "$ref": "#/components/schemas/to-many-passenger-request-data" } @@ -2648,6 +2783,9 @@ "additionalProperties": false }, "flight-relationships-in-post-request": { + "required": [ + "purser" + ], "type": "object", "properties": { "cabin-crew-members": { @@ -2656,6 +2794,9 @@ "purser": { "$ref": "#/components/schemas/to-one-flight-attendant-request-data" }, + "backup-purser": { + "$ref": "#/components/schemas/nullable-to-one-flight-attendant-request-data" + }, "passengers": { "$ref": "#/components/schemas/to-many-passenger-request-data" } @@ -2671,6 +2812,9 @@ "purser": { "$ref": "#/components/schemas/to-one-flight-attendant-response-data" }, + "backup-purser": { + "$ref": "#/components/schemas/nullable-to-one-flight-attendant-response-data" + }, "passengers": { "$ref": "#/components/schemas/to-many-passenger-response-data" } @@ -2853,6 +2997,111 @@ }, "nullable": true }, + "nullable-flight-attendant-identifier-response-document": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/flight-attendant-identifier" + }, + { + "$ref": "#/components/schemas/null-value" + } + ] + }, + "meta": { + "type": "object", + "additionalProperties": {} + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi-object" + }, + "links": { + "$ref": "#/components/schemas/links-in-resource-identifier-document" + } + }, + "additionalProperties": false + }, + "nullable-flight-attendant-secondary-response-document": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/flight-attendant-data-in-response" + }, + { + "$ref": "#/components/schemas/null-value" + } + ] + }, + "meta": { + "type": "object", + "additionalProperties": {} + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi-object" + }, + "links": { + "$ref": "#/components/schemas/links-in-resource-document" + } + }, + "additionalProperties": false + }, + "nullable-to-one-flight-attendant-request-data": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/flight-attendant-identifier" + }, + { + "$ref": "#/components/schemas/null-value" + } + ] + } + }, + "additionalProperties": false + }, + "nullable-to-one-flight-attendant-response-data": { + "required": [ + "links" + ], + "type": "object", + "properties": { + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/flight-attendant-identifier" + }, + { + "$ref": "#/components/schemas/null-value" + } + ] + }, + "links": { + "$ref": "#/components/schemas/links-in-relationship-object" + }, + "meta": { + "type": "object", + "additionalProperties": {} + } + }, + "additionalProperties": false + }, "passenger-attributes-in-response": { "type": "object", "properties": { @@ -3085,14 +3334,7 @@ "type": "object", "properties": { "data": { - "oneOf": [ - { - "$ref": "#/components/schemas/flight-attendant-identifier" - }, - { - "$ref": "#/components/schemas/null-value" - } - ] + "$ref": "#/components/schemas/flight-attendant-identifier" } }, "additionalProperties": false @@ -3104,14 +3346,7 @@ "type": "object", "properties": { "data": { - "oneOf": [ - { - "$ref": "#/components/schemas/flight-attendant-identifier" - }, - { - "$ref": "#/components/schemas/null-value" - } - ] + "$ref": "#/components/schemas/flight-attendant-identifier" }, "links": { "$ref": "#/components/schemas/links-in-relationship-object"