From bf7f1b396b088b3613aecc4f0c44d5f1addf32a9 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 31 Aug 2025 14:28:22 +0200 Subject: [PATCH 1/4] Simplify ordering calls --- src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs | 2 +- .../Queries/Expressions/IncludeElementExpression.cs | 2 +- .../Queries/Expressions/IncludeExpression.cs | 2 +- .../Queries/Expressions/SparseFieldSetExpression.cs | 4 ++-- .../IntegrationTests/Serialization/SerializationTests.cs | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs index 6b44b7d52f..799232b155 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs @@ -58,7 +58,7 @@ private string InnerToString(bool toFullString) builder.Append('('); builder.Append(toFullString ? TargetAttribute.ToFullString() : TargetAttribute.ToString()); builder.Append(','); - builder.Append(string.Join(',', Constants.Select(constant => toFullString ? constant.ToFullString() : constant.ToString()).OrderBy(value => value))); + builder.Append(string.Join(',', Constants.Select(constant => toFullString ? constant.ToFullString() : constant.ToString()).Order())); builder.Append(')'); return builder.ToString(); diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs index 3ee86e6ea2..e008f8de6b 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs @@ -62,7 +62,7 @@ private string InnerToString(bool toFullString) if (Children.Count > 0) { builder.Append('{'); - builder.Append(string.Join(',', Children.Select(child => toFullString ? child.ToFullString() : child.ToString()).OrderBy(name => name))); + builder.Append(string.Join(',', Children.Select(child => toFullString ? child.ToFullString() : child.ToString()).Order())); builder.Append('}'); } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs index 71d6bf84c2..bc6982af5a 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs @@ -50,7 +50,7 @@ public override string ToFullString() private string InnerToString(bool toFullString) { IReadOnlyCollection chains = IncludeChainConverter.Instance.GetRelationshipChains(this); - return string.Join(',', chains.Select(field => toFullString ? field.ToFullString() : field.ToString()).Distinct().OrderBy(name => name)); + return string.Join(',', chains.Select(field => toFullString ? field.ToFullString() : field.ToString()).Distinct().Order()); } public override bool Equals(object? obj) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs index ddf11f2f0a..7a4495bd83 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs @@ -33,12 +33,12 @@ public override TResult Accept(QueryExpressionVisitor field.ToString()).OrderBy(name => name)); + return string.Join(',', Fields.Select(field => field.ToString()).Order()); } public override string ToFullString() { - return string.Join(',', Fields.Select(field => $"{field.ToFullString()}").OrderBy(name => name)); + return string.Join(',', Fields.Select(field => $"{field.ToFullString()}").Order()); } public override bool Equals(object? obj) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs index e500ebcdf9..8eb6091d82 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs @@ -613,7 +613,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - string[] meetingIds = [.. meeting.Attendees.Select(attendee => attendee.StringId!).OrderBy(id => id)]; + string[] meetingIds = [.. meeting.Attendees.Select(attendee => attendee.StringId!).Order()]; responseDocument.Should().BeJson($$""" { From 3c0f0e91b6a872d2fbbea25119ddd9280d45bf68 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 6 Sep 2025 16:33:06 +0200 Subject: [PATCH 2/4] Add switch to build-dev.ps1 to skip opening docs in new browser tab --- docs/build-dev.ps1 | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/build-dev.ps1 b/docs/build-dev.ps1 index 0a2f5eec28..4fb2b7b425 100644 --- a/docs/build-dev.ps1 +++ b/docs/build-dev.ps1 @@ -4,7 +4,9 @@ param( # Specify -NoBuild to skip code build and examples generation. This runs faster, so handy when only editing Markdown files. - [switch] $NoBuild=$False + [switch] $NoBuild=$False, + # Specify -NoOpen to skip opening the documentation website in a web browser. + [switch] $NoOpen=$False ) function VerifySuccessExitCode { @@ -53,9 +55,12 @@ Copy-Item -Force -Recurse home/assets/* _site/styles/ cd _site $webServerJob = httpserver & -Start-Process "http://localhost:8080/" cd .. +if (-Not $NoOpen) { + Start-Process "http://localhost:8080/" +} + Write-Host "" Write-Host "Web server started. Press Enter to close." $key = [Console]::ReadKey() From 2202019ba0e2232ffe017fc8b6043b919173c1e0 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 6 Sep 2025 16:33:59 +0200 Subject: [PATCH 3/4] Remove redundant type parameter --- .../IntegrationTests/QueryStrings/QueryStringDbContext.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs index 4ea8f13b51..93a5ec5e65 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs @@ -36,7 +36,7 @@ protected override void OnModelCreating(ModelBuilder builder) .HasForeignKey(); builder.Entity() - .HasMany(calendar => calendar.Appointments) + .HasMany(calendar => calendar.Appointments) .WithOne(appointment => appointment.Calendar); base.OnModelCreating(builder); From 9a8101842b59295ba15043e1ab3f152a6ead1014 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 6 Sep 2025 16:46:52 +0200 Subject: [PATCH 4/4] Add OpenAPI support for custom JSON:API action methods Moves success/error status codes into metadata to support custom JSON:API action methods. Refactored internal type hierarchy: [Before] OpenApiActionMethod CustomControllerActionMethod BuiltinJsonApiActionMethod { ControllerType } AtomicOperationsActionMethod JsonApiActionMethod { Endpoint } [After] JsonApiActionMethod { ControllerType } OperationsActionMethod ResourceActionMethod BuiltinResourceActionMethod { Endpoint } CustomResourceActionMethod { Descriptor } --- docs/usage/extensibility/controllers.md | 53 ++ docs/usage/openapi.md | 43 +- .../ActionDescriptorExtensions.cs | 7 +- .../ConfigureSwaggerGenOptions.cs | 8 +- ...onApiActionDescriptorCollectionProvider.cs | 202 +++---- .../AtomicOperationsActionMethod.cs | 9 - .../BuiltinJsonApiActionMethod.cs | 18 - .../BuiltinResourceActionMethod.cs | 17 + .../CustomControllerActionMethod.cs | 13 - .../CustomJsonApiActionMethod.cs | 15 - .../CustomResourceActionMethod.cs | 20 + .../ActionMethods/JsonApiActionMethod.cs | 69 ++- .../ActionMethods/OpenApiActionMethod.cs | 58 -- .../ActionMethods/OperationsActionMethod.cs | 9 + .../ActionMethods/ResourceActionMethod.cs | 9 + .../AtomicOperationsResponseMetadata.cs | 17 + .../EmptyRelationshipResponseMetadata.cs | 11 +- .../Documents/NonPrimaryResponseMetadata.cs | 11 +- .../Documents/PrimaryResponseMetadata.cs | 19 +- .../Documents/RelationshipResponseMetadata.cs | 7 +- .../Documents/SecondaryResponseMetadata.cs | 7 +- .../JsonApiEndpointMetadataProvider.cs | 280 +++++++++- .../MethodInfoWrapper.cs | 168 ++++++ .../OpenApiOperationIdSelector.cs | 21 +- .../ParameterInfoWrapper.cs | 2 + .../GenerationCacheSchemaGenerator.cs | 4 +- .../DocumentationOpenApiOperationFilter.cs | 12 +- .../CoffeeSummariesRequestBuilder.cs | 45 ++ .../Summary/SummaryRequestBuilder.cs | 105 ++++ .../CupOfCoffees/Batch/BatchRequestBuilder.cs | 148 +++++ .../CupOfCoffeesRequestBuilder.cs | 14 + .../Item/OnlyBlackItemRequestBuilder.cs | 105 ++++ .../OnlyBlack/OnlyBlackRequestBuilder.cs | 114 ++++ .../GeneratedCode/MixedControllersClient.cs | 7 + .../AttributesInCoffeeSummaryResponse.cs | 95 ++++ .../AttributesInCreateCupOfCoffeeRequest.cs | 68 +++ .../Models/AttributesInCreateRequest.cs | 75 +++ .../Models/AttributesInResponse.cs | 1 + .../CreateCupOfCoffeeRequestDocument.cs | 79 +++ .../Models/DataInCoffeeSummaryResponse.cs | 77 +++ .../Models/DataInCreateCupOfCoffeeRequest.cs | 59 ++ .../PrimaryCoffeeSummaryResponseDocument.cs | 97 ++++ .../PrimaryCupOfCoffeeResponseDocument.cs | 97 ++++ .../Models/ResourceInCreateRequest.cs | 84 +++ .../Models/ResourceInResponse.cs | 1 + .../Models/ResourceTopLevelLinks.cs | 79 +++ .../GeneratedCode/Models/ResourceType.cs | 4 + .../MixedControllers/MixedControllerTests.cs | 318 ++++++++++- .../GeneratedCode/MixedControllersClient.cs | 19 + .../MixedControllers/MixedControllerTests.cs | 312 ++++++++++- .../CoffeeSummaryController.cs | 28 +- .../CupOfCoffeesController.cs | 129 +++++ .../GeneratedSwagger/swagger.g.json | 515 ++++++++++++++++++ .../MixedControllers/LoggingTests.cs | 51 -- .../MixedControllers/MixedControllerTests.cs | 301 ++++++++++ 55 files changed, 3752 insertions(+), 384 deletions(-) delete mode 100644 src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/AtomicOperationsActionMethod.cs delete mode 100644 src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/BuiltinJsonApiActionMethod.cs create mode 100644 src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/BuiltinResourceActionMethod.cs delete mode 100644 src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/CustomControllerActionMethod.cs delete mode 100644 src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/CustomJsonApiActionMethod.cs create mode 100644 src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/CustomResourceActionMethod.cs delete mode 100644 src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/OpenApiActionMethod.cs create mode 100644 src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/OperationsActionMethod.cs create mode 100644 src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/ResourceActionMethod.cs create mode 100644 src/JsonApiDotNetCore.OpenApi.Swashbuckle/MethodInfoWrapper.cs create mode 100644 test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/CoffeeSummaries/CoffeeSummariesRequestBuilder.cs create mode 100644 test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/CoffeeSummaries/Summary/SummaryRequestBuilder.cs create mode 100644 test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/CupOfCoffees/Batch/BatchRequestBuilder.cs create mode 100644 test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/CupOfCoffees/OnlyBlack/Item/OnlyBlackItemRequestBuilder.cs create mode 100644 test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/CupOfCoffees/OnlyBlack/OnlyBlackRequestBuilder.cs create mode 100644 test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/AttributesInCoffeeSummaryResponse.cs create mode 100644 test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/AttributesInCreateCupOfCoffeeRequest.cs create mode 100644 test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/AttributesInCreateRequest.cs create mode 100644 test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/CreateCupOfCoffeeRequestDocument.cs create mode 100644 test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/DataInCoffeeSummaryResponse.cs create mode 100644 test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/DataInCreateCupOfCoffeeRequest.cs create mode 100644 test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/PrimaryCoffeeSummaryResponseDocument.cs create mode 100644 test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/PrimaryCupOfCoffeeResponseDocument.cs create mode 100644 test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/ResourceInCreateRequest.cs create mode 100644 test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/ResourceTopLevelLinks.cs create mode 100644 test/OpenApiNSwagEndToEndTests/MixedControllers/GeneratedCode/MixedControllersClient.cs create mode 100644 test/OpenApiTests/MixedControllers/CupOfCoffeesController.cs delete mode 100644 test/OpenApiTests/MixedControllers/LoggingTests.cs diff --git a/docs/usage/extensibility/controllers.md b/docs/usage/extensibility/controllers.md index 254b305ed9..94a1f93a8e 100644 --- a/docs/usage/extensibility/controllers.md +++ b/docs/usage/extensibility/controllers.md @@ -137,3 +137,56 @@ public class ReportsController : JsonApiController ``` For more information about resource service injection, see [Replacing injected services](~/usage/extensibility/layer-overview.md#replacing-injected-services) and [Resource Services](~/usage/extensibility/services.md). + +## Custom action methods + +Aside from adding custom ASP.NET controllers and Minimal API endpoints to your project that are unrelated to JSON:API, +you can also augment JsonApiDotNetCore controllers with custom action methods. +This applies to both auto-generated and explicit controllers. + +When doing so, they participate in the JsonApiDotNetCore pipeline, which means that JSON:API query string parameters are available, +exceptions are handled, and the request/response bodies match the JSON:API structure. As a result, the following restrictions apply: + +- The input/output resource types used must exist in the resource graph. +- For primary endpoints, the input/output resource types must match the controller resource type. +- An action method can only return a resource, a collection of resources, an error, or null. + +For example, the following custom POST endpoint doesn't take a request body and returns a collection of resources: + +```c# +partial class TagsController +{ + // POST /tags/defaults + [HttpPost("defaults")] + public async Task CreateDefaultTagsAsync() + { + List defaultTagNames = + [ + "Create design", + "Implement feature", + "Write tests", + "Update documentation", + "Deploy changes" + ]; + + bool hasDefaultTags = await _appDbContext.Tags.AnyAsync(tag => defaultTagNames.Contains(tag.Name)); + if (hasDefaultTags) + { + throw new JsonApiException(new ErrorObject(HttpStatusCode.Conflict) + { + Title = "Default tags already exist." + }); + } + + List defaultTags = defaultTagNames.Select(name => new Tag + { + Name = name + }).ToList(); + + _appDbContext.Tags.AddRange(defaultTags); + await _appDbContext.SaveChangesAsync(); + + return Ok(defaultTags); + } +} +``` diff --git a/docs/usage/openapi.md b/docs/usage/openapi.md index 83f3ce8a3b..c27fcd50b4 100644 --- a/docs/usage/openapi.md +++ b/docs/usage/openapi.md @@ -37,10 +37,13 @@ provides OpenAPI support for JSON:API by integrating with [Swashbuckle](https:// By default, the OpenAPI document will be available at `http://localhost:/swagger/v1/swagger.json`. +> [!TIP] +> In addition to the documentation here, various examples can be found in the [OpenApiTests project](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/OpenApiTests). + ### Customizing the Route Template Because Swashbuckle doesn't properly implement the ASP.NET Options pattern, you must *not* use its -[documented way](https://github.com/domaindrivendev/Swashbuckle.AspNetCore?tab=readme-ov-file#change-the-path-for-swagger-json-endpoints) +[documented way](https://github.com/domaindrivendev/Swashbuckle.AspNetCore/blob/master/docs/configure-and-customize-swagger.md#change-the-path-for-swagger-json-endpoints) to change the route template: ```c# @@ -78,6 +81,40 @@ The `NoWarn` line is optional, which suppresses build warnings for undocumented ``` -You can combine this with the documentation that Swagger itself supports, by enabling it as described -[here](https://github.com/domaindrivendev/Swashbuckle.AspNetCore#include-descriptions-from-xml-comments). +You can combine this with the documentation that Swashbuckle itself supports, by enabling it as described +[here](https://github.com/domaindrivendev/Swashbuckle.AspNetCore/blob/master/docs/configure-and-customize-swaggergen.md#include-descriptions-from-xml-comments). This adds documentation for additional types, such as triple-slash comments on enums used in your resource models. + +## Custom JSON:API action methods + +To express the metadata of [custom action methods](~/usage/extensibility/controllers.md#custom-action-methods) in OpenAPI, +use the following attributes on your controller action method: + +- The `Name` property on `HttpMethodAttribute` to specify the OpenAPI operation ID, for example: + ```c# + [HttpGet("active", Name = "get-active-users")] + ``` + +- `EndpointDescriptionAttribute` to specify the OpenAPI endpoint description, for example: + ```c# + [EndpointDescription("Provides access to user accounts.")] + ``` + +- `ConsumesAttribute` to specify the resource type of the request body, for example: + ```c# + [Consumes(typeof(UserAccount), "application/vnd.api+json")] + ``` + > [!NOTE] + > The `contentType` parameter is required, but effectively ignored. + +- `ProducesResponseTypeAttribute` attribute(s) to specify the response types and status codes, for example: + ```c# + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + ``` + > [!NOTE] + > For non-success response status codes, the type should be omitted. + +Custom parameters on action methods can be decorated with the usual attributes, such as `[Required]`, `[Description]`, etc. diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ActionDescriptorExtensions.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ActionDescriptorExtensions.cs index 6993d10cdf..87e01a255b 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ActionDescriptorExtensions.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ActionDescriptorExtensions.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCore.OpenApi.Swashbuckle; internal static class ActionDescriptorExtensions { - public static MethodInfo GetActionMethod(this ActionDescriptor descriptor) + public static MethodInfo? TryGetActionMethod(this ActionDescriptor descriptor) { ArgumentNullException.ThrowIfNull(descriptor); @@ -16,10 +16,7 @@ public static MethodInfo GetActionMethod(this ActionDescriptor descriptor) return controllerActionDescriptor.MethodInfo; } - MethodInfo? methodInfo = descriptor.EndpointMetadata.OfType().FirstOrDefault(); - ConsistencyGuard.ThrowIf(methodInfo == null); - - return methodInfo; + return descriptor.EndpointMetadata.OfType().FirstOrDefault(); } public static ControllerParameterDescriptor? GetBodyParameterDescriptor(this ActionDescriptor descriptor) diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ConfigureSwaggerGenOptions.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ConfigureSwaggerGenOptions.cs index efef31c7e4..ec21712688 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ConfigureSwaggerGenOptions.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ConfigureSwaggerGenOptions.cs @@ -146,17 +146,17 @@ private static void IncludeDerivedTypes(ResourceType baseType, List clrTyp private static IList GetOpenApiOperationTags(ApiDescription description, IControllerResourceMapping controllerResourceMapping) { - var actionMethod = OpenApiActionMethod.Create(description.ActionDescriptor); + var actionMethod = JsonApiActionMethod.TryCreate(description.ActionDescriptor); switch (actionMethod) { - case AtomicOperationsActionMethod: + case OperationsActionMethod: { return ["operations"]; } - case JsonApiActionMethod jsonApiActionMethod: + case ResourceActionMethod resourceActionMethod: { - ResourceType? resourceType = controllerResourceMapping.GetResourceTypeForController(jsonApiActionMethod.ControllerType); + ResourceType? resourceType = controllerResourceMapping.GetResourceTypeForController(resourceActionMethod.ControllerType); ConsistencyGuard.ThrowIf(resourceType == null); return [resourceType.PublicName]; diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiActionDescriptorCollectionProvider.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiActionDescriptorCollectionProvider.cs index 5d05490e8d..250e9b92fc 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiActionDescriptorCollectionProvider.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiActionDescriptorCollectionProvider.cs @@ -35,7 +35,6 @@ internal sealed partial class JsonApiActionDescriptorCollectionProvider : IActio private readonly IActionDescriptorCollectionProvider _defaultProvider; private readonly IControllerResourceMapping _controllerResourceMapping; private readonly JsonApiEndpointMetadataProvider _jsonApiEndpointMetadataProvider; - private readonly IJsonApiOptions _options; private readonly ILogger _logger; private readonly ConcurrentDictionary> _versionedActionDescriptorCache = new(); @@ -43,18 +42,16 @@ internal sealed partial class JsonApiActionDescriptorCollectionProvider : IActio _versionedActionDescriptorCache.GetOrAdd(_defaultProvider.ActionDescriptors.Version, LazyGetActionDescriptors).Value; public JsonApiActionDescriptorCollectionProvider(IActionDescriptorCollectionProvider defaultProvider, IControllerResourceMapping controllerResourceMapping, - JsonApiEndpointMetadataProvider jsonApiEndpointMetadataProvider, IJsonApiOptions options, ILogger logger) + JsonApiEndpointMetadataProvider jsonApiEndpointMetadataProvider, ILogger logger) { ArgumentNullException.ThrowIfNull(defaultProvider); ArgumentNullException.ThrowIfNull(controllerResourceMapping); ArgumentNullException.ThrowIfNull(jsonApiEndpointMetadataProvider); - ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(logger); _defaultProvider = defaultProvider; _controllerResourceMapping = controllerResourceMapping; _jsonApiEndpointMetadataProvider = jsonApiEndpointMetadataProvider; - _options = options; _logger = logger; } @@ -76,37 +73,28 @@ private ActionDescriptorCollection GetActionDescriptors(int version) continue; } - var actionMethod = OpenApiActionMethod.Create(descriptor); + var actionMethod = JsonApiActionMethod.TryCreate(descriptor); - if (actionMethod is CustomJsonApiActionMethod) - { - // A non-standard action method in a JSON:API controller. Not yet implemented, so skip to prevent downstream crashes. - string httpMethods = string.Join(", ", descriptor.EndpointMetadata.OfType().SelectMany(metadata => metadata.HttpMethods)); - LogSuppressedActionMethod(httpMethods, descriptor.DisplayName); - - continue; - } - - if (actionMethod is BuiltinJsonApiActionMethod builtinActionMethod) + if (actionMethod != null) { if (!IsVisibleEndpoint(descriptor)) { continue; } - ResourceType? resourceType = _controllerResourceMapping.GetResourceTypeForController(builtinActionMethod.ControllerType); + ResourceType? resourceType = _controllerResourceMapping.GetResourceTypeForController(actionMethod.ControllerType); - if (builtinActionMethod is JsonApiActionMethod jsonApiActionMethod) + if (actionMethod is BuiltinResourceActionMethod builtinResourceActionMethod) { ConsistencyGuard.ThrowIf(resourceType == null); - if (ShouldSuppressEndpoint(jsonApiActionMethod.Endpoint, resourceType)) + if (ShouldSuppressEndpoint(builtinResourceActionMethod.Endpoint, resourceType)) { continue; } } - ActionDescriptor[] replacementDescriptors = SetEndpointMetadata(descriptor, builtinActionMethod, resourceType); + ActionDescriptor[] replacementDescriptors = SetEndpointMetadata(descriptor); descriptors.AddRange(replacementDescriptors); continue; @@ -225,9 +213,10 @@ private static bool IsSecondaryOrRelationshipEndpoint(JsonApiEndpoints endpoint) JsonApiEndpoints.PatchRelationship or JsonApiEndpoints.DeleteRelationship; } - private ActionDescriptor[] SetEndpointMetadata(ActionDescriptor descriptor, BuiltinJsonApiActionMethod actionMethod, ResourceType? resourceType) + private ActionDescriptor[] SetEndpointMetadata(ActionDescriptor descriptor) { Dictionary descriptorsByRelationship = []; + bool isNonPrimaryEndpoint = false; JsonApiEndpointMetadata endpointMetadata = _jsonApiEndpointMetadataProvider.Get(descriptor); @@ -250,6 +239,7 @@ private ActionDescriptor[] SetEndpointMetadata(ActionDescriptor descriptor, Buil case RelationshipRequestMetadata relationshipRequestMetadata: { ConsistencyGuard.ThrowIf(descriptor.AttributeRouteInfo == null); + isNonPrimaryEndpoint = true; foreach ((RelationshipAttribute relationship, Type documentType) in relationshipRequestMetadata.DocumentTypesByRelationship) { @@ -272,55 +262,90 @@ private ActionDescriptor[] SetEndpointMetadata(ActionDescriptor descriptor, Buil case AtomicOperationsResponseMetadata atomicOperationsResponseMetadata: { SetProduces(descriptor, atomicOperationsResponseMetadata.DocumentType); - SetProducesResponseTypes(descriptor, actionMethod, resourceType, atomicOperationsResponseMetadata.DocumentType); + + SetProducesResponseTypes(descriptor, atomicOperationsResponseMetadata.DocumentType, atomicOperationsResponseMetadata.SuccessStatusCodes, + atomicOperationsResponseMetadata.ErrorStatusCodes); break; } case PrimaryResponseMetadata primaryResponseMetadata: { SetProduces(descriptor, primaryResponseMetadata.DocumentType); - SetProducesResponseTypes(descriptor, actionMethod, resourceType, primaryResponseMetadata.DocumentType); + + SetProducesResponseTypes(descriptor, primaryResponseMetadata.DocumentType, primaryResponseMetadata.SuccessStatusCodes, + primaryResponseMetadata.ErrorStatusCodes); + break; } case NonPrimaryResponseMetadata nonPrimaryResponseMetadata: { + isNonPrimaryEndpoint = true; + foreach ((RelationshipAttribute relationship, Type documentType) in nonPrimaryResponseMetadata.DocumentTypesByRelationship) { - SetNonPrimaryResponseMetadata(descriptor, actionMethod, resourceType, descriptorsByRelationship, relationship, documentType); + SetNonPrimaryResponseMetadata(descriptor, descriptorsByRelationship, relationship, documentType, + nonPrimaryResponseMetadata.SuccessStatusCodes, nonPrimaryResponseMetadata.ErrorStatusCodes); } break; } case EmptyRelationshipResponseMetadata emptyRelationshipResponseMetadata: { + isNonPrimaryEndpoint = true; + foreach (RelationshipAttribute relationship in emptyRelationshipResponseMetadata.Relationships) { - SetNonPrimaryResponseMetadata(descriptor, actionMethod, resourceType, descriptorsByRelationship, relationship, null); + SetNonPrimaryResponseMetadata(descriptor, descriptorsByRelationship, relationship, null, + emptyRelationshipResponseMetadata.SuccessStatusCodes, emptyRelationshipResponseMetadata.ErrorStatusCodes); } break; } } - return descriptorsByRelationship.Count == 0 ? [descriptor] : descriptorsByRelationship.Values.ToArray(); + return isNonPrimaryEndpoint ? descriptorsByRelationship.Values.ToArray() : [descriptor]; } private static void SetConsumes(ActionDescriptor descriptor, Type requestType, JsonApiMediaType mediaType) { + RemoveFiltersForRequestBody(descriptor); + // This value doesn't actually appear in the OpenAPI document, but is only used to invoke // JsonApiRequestFormatMetadataProvider.GetSupportedContentTypes(), which determines the actual request content type. string contentType = mediaType.ToString(); + if (descriptor is ControllerActionDescriptor controllerActionDescriptor && + controllerActionDescriptor.MethodInfo.GetCustomAttributes(typeof(ConsumesAttribute)).Any()) + { + // A custom JSON:API action method with [Consumes] on it. Hide the attribute from Swashbuckle, so it uses our data in API Explorer. + controllerActionDescriptor.MethodInfo = new MethodInfoWrapper(controllerActionDescriptor.MethodInfo, [typeof(ConsumesAttribute)]); + } + descriptor.FilterDescriptors.Add(new FilterDescriptor(new ConsumesAttribute(requestType, contentType), FilterScope)); } + private static void RemoveFiltersForRequestBody(ActionDescriptor descriptor) + { + // Custom action methods that take a request body are expected to be annotated with [Consumes]. + // We add the CLR type, so that an IIdentifiable type is lifted to a JSON:API type, which is why the existing annotation must be replaced. + + foreach (FilterDescriptor filterDescriptor in descriptor.FilterDescriptors.ToArray()) + { + if (filterDescriptor.Filter is ConsumesAttribute) + { + descriptor.FilterDescriptors.Remove(filterDescriptor); + } + } + } + private static void UpdateRequestBodyParameterDescriptor(ActionDescriptor descriptor, Type documentType, string? parameterName) { ControllerParameterDescriptor? requestBodyDescriptor = descriptor.GetBodyParameterDescriptor(); if (requestBodyDescriptor == null) { - MethodInfo actionMethod = descriptor.GetActionMethod(); + MethodInfo? actionMethod = descriptor.TryGetActionMethod(); + ConsistencyGuard.ThrowIf(actionMethod == null); throw new InvalidConfigurationException( $"The action method '{actionMethod}' on type '{actionMethod.ReflectedType?.FullName}' contains no parameter with a [FromBody] attribute."); @@ -362,7 +387,7 @@ private static void ExpandTemplate(AttributeRouteInfo route, string parameterNam route.Template = route.Template!.Replace("{relationshipName}", parameterName); } - private void SetProduces(ActionDescriptor descriptor, Type? documentType) + private static void SetProduces(ActionDescriptor descriptor, Type? documentType) { IReadOnlyList contentTypes = OpenApiContentTypeProvider.Instance.GetResponseContentTypes(documentType); @@ -372,10 +397,13 @@ private void SetProduces(ActionDescriptor descriptor, Type? documentType) } } - private void SetProducesResponseTypes(ActionDescriptor descriptor, BuiltinJsonApiActionMethod actionMethod, ResourceType? resourceType, Type? documentType) + private static void SetProducesResponseTypes(ActionDescriptor descriptor, Type? documentType, IReadOnlyCollection successStatusCodes, + IReadOnlyCollection errorStatusCodes) { - foreach (HttpStatusCode statusCode in GetSuccessStatusCodesForActionMethod(actionMethod)) + foreach (HttpStatusCode statusCode in successStatusCodes.Order()) { + RemoveFiltersForStatusCode(descriptor, statusCode); + descriptor.FilterDescriptors.Add(documentType == null || StatusCodeHasNoResponseBody(statusCode) ? new FilterDescriptor(new ProducesResponseTypeAttribute(typeof(void), (int)statusCode), FilterScope) : new FilterDescriptor(new ProducesResponseTypeAttribute(documentType, (int)statusCode), FilterScope)); @@ -390,123 +418,51 @@ private void SetProducesResponseTypes(ActionDescriptor descriptor, BuiltinJsonAp errorContentType = errorContentTypes[0]; } - foreach (HttpStatusCode statusCode in GetErrorStatusCodesForActionMethod(actionMethod, resourceType)) + foreach (HttpStatusCode statusCode in errorStatusCodes.Order()) { + RemoveFiltersForStatusCode(descriptor, statusCode); + descriptor.FilterDescriptors.Add(errorContentType != null ? new FilterDescriptor(new ProducesResponseTypeAttribute(ErrorDocumentType, (int)statusCode, errorContentType), FilterScope) : new FilterDescriptor(new ProducesResponseTypeAttribute(ErrorDocumentType, (int)statusCode), FilterScope)); } } - private static HttpStatusCode[] GetSuccessStatusCodesForActionMethod(BuiltinJsonApiActionMethod actionMethod) + private static void RemoveFiltersForStatusCode(ActionDescriptor descriptor, HttpStatusCode statusCode) { - HttpStatusCode[]? statusCodes = null; + // Custom action methods are expected to be annotated with [ProducesResponseType] to express (1) the return type(s) on success and + // (2) possible error status codes. We add the CLR types, so that IIdentifiable types are lifted to JSON:API types, which is why + // the existing annotations must be replaced. - if (actionMethod is AtomicOperationsActionMethod) - { - statusCodes = - [ - HttpStatusCode.OK, - HttpStatusCode.NoContent - ]; - } - else if (actionMethod is JsonApiActionMethod jsonApiActionMethod) + foreach (FilterDescriptor filterDescriptor in descriptor.FilterDescriptors.ToArray()) { - statusCodes = jsonApiActionMethod.Endpoint switch + if (filterDescriptor.Filter is ProducesResponseTypeAttribute produces && produces.StatusCode == (int)statusCode) { - JsonApiEndpoints.GetCollection or JsonApiEndpoints.GetSingle or JsonApiEndpoints.GetSecondary or JsonApiEndpoints.GetRelationship => - [ - HttpStatusCode.OK, - HttpStatusCode.NotModified - ], - JsonApiEndpoints.Post => - [ - HttpStatusCode.Created, - HttpStatusCode.NoContent - ], - JsonApiEndpoints.Patch => - [ - HttpStatusCode.OK, - HttpStatusCode.NoContent - ], - JsonApiEndpoints.Delete or JsonApiEndpoints.PostRelationship or JsonApiEndpoints.PatchRelationship or JsonApiEndpoints.DeleteRelationship => - [ - HttpStatusCode.NoContent - ], - _ => null - }; + descriptor.FilterDescriptors.Remove(filterDescriptor); + } } - - ConsistencyGuard.ThrowIf(statusCodes == null); - return statusCodes; } private static bool StatusCodeHasNoResponseBody(HttpStatusCode statusCode) { - return statusCode is HttpStatusCode.NoContent or HttpStatusCode.NotModified; - } + int value = (int)statusCode; - private HttpStatusCode[] GetErrorStatusCodesForActionMethod(BuiltinJsonApiActionMethod actionMethod, ResourceType? resourceType) - { - HttpStatusCode[]? statusCodes = null; - - if (actionMethod is AtomicOperationsActionMethod) + if (value < 200) { - statusCodes = - [ - HttpStatusCode.BadRequest, - HttpStatusCode.Forbidden, - HttpStatusCode.NotFound, - HttpStatusCode.Conflict, - HttpStatusCode.UnprocessableEntity - ]; + return true; } - else if (actionMethod is JsonApiActionMethod jsonApiActionMethod) - { - // Condition doesn't apply to atomic operations, because Forbidden is also used when an operation is not accessible. - ClientIdGenerationMode clientIdGeneration = resourceType?.ClientIdGeneration ?? _options.ClientIdGeneration; - statusCodes = jsonApiActionMethod.Endpoint switch - { - JsonApiEndpoints.GetCollection => [HttpStatusCode.BadRequest], - JsonApiEndpoints.GetSingle or JsonApiEndpoints.GetSecondary or JsonApiEndpoints.GetRelationship => - [ - HttpStatusCode.BadRequest, - HttpStatusCode.NotFound - ], - JsonApiEndpoints.Post when clientIdGeneration == ClientIdGenerationMode.Forbidden => - [ - HttpStatusCode.BadRequest, - HttpStatusCode.Forbidden, - HttpStatusCode.NotFound, - HttpStatusCode.Conflict, - HttpStatusCode.UnprocessableEntity - ], - JsonApiEndpoints.Post or JsonApiEndpoints.Patch => - [ - HttpStatusCode.BadRequest, - HttpStatusCode.NotFound, - HttpStatusCode.Conflict, - HttpStatusCode.UnprocessableEntity - ], - JsonApiEndpoints.Delete => [HttpStatusCode.NotFound], - JsonApiEndpoints.PostRelationship or JsonApiEndpoints.PatchRelationship or JsonApiEndpoints.DeleteRelationship => - [ - HttpStatusCode.BadRequest, - HttpStatusCode.NotFound, - HttpStatusCode.Conflict, - HttpStatusCode.UnprocessableEntity - ], - _ => null - }; + if (value is >= 300 and < 400) + { + return true; } - ConsistencyGuard.ThrowIf(statusCodes == null); - return statusCodes; + return statusCode is HttpStatusCode.NoContent or HttpStatusCode.ResetContent; } - private void SetNonPrimaryResponseMetadata(ActionDescriptor descriptor, BuiltinJsonApiActionMethod actionMethod, ResourceType? resourceType, - Dictionary descriptorsByRelationship, RelationshipAttribute relationship, Type? documentType) + private static void SetNonPrimaryResponseMetadata(ActionDescriptor descriptor, + Dictionary descriptorsByRelationship, RelationshipAttribute relationship, Type? documentType, + IReadOnlyCollection successStatusCodes, IReadOnlyCollection errorStatusCodes) { ConsistencyGuard.ThrowIf(descriptor.AttributeRouteInfo == null); @@ -518,7 +474,7 @@ private void SetNonPrimaryResponseMetadata(ActionDescriptor descriptor, BuiltinJ ExpandTemplate(relationshipDescriptor.AttributeRouteInfo!, relationship.PublicName); SetProduces(relationshipDescriptor, documentType); - SetProducesResponseTypes(relationshipDescriptor, actionMethod, resourceType, documentType); + SetProducesResponseTypes(relationshipDescriptor, documentType, successStatusCodes, errorStatusCodes); descriptorsByRelationship[relationship] = relationshipDescriptor; } diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/AtomicOperationsActionMethod.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/AtomicOperationsActionMethod.cs deleted file mode 100644 index d80a86cd06..0000000000 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/AtomicOperationsActionMethod.cs +++ /dev/null @@ -1,9 +0,0 @@ -using JsonApiDotNetCore.Controllers; - -namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.ActionMethods; - -/// -/// The built-in JSON:API operations action method . -/// -internal sealed class AtomicOperationsActionMethod(Type controllerType) - : BuiltinJsonApiActionMethod(controllerType); diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/BuiltinJsonApiActionMethod.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/BuiltinJsonApiActionMethod.cs deleted file mode 100644 index a6374801b3..0000000000 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/BuiltinJsonApiActionMethod.cs +++ /dev/null @@ -1,18 +0,0 @@ -using JsonApiDotNetCore.Controllers; - -namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.ActionMethods; - -/// -/// A built-in JSON:API action method on . -/// -internal abstract class BuiltinJsonApiActionMethod : OpenApiActionMethod -{ - public Type ControllerType { get; } - - protected BuiltinJsonApiActionMethod(Type controllerType) - { - ArgumentNullException.ThrowIfNull(controllerType); - - ControllerType = controllerType; - } -} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/BuiltinResourceActionMethod.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/BuiltinResourceActionMethod.cs new file mode 100644 index 0000000000..1b4d0bb052 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/BuiltinResourceActionMethod.cs @@ -0,0 +1,17 @@ +using JsonApiDotNetCore.Controllers; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.ActionMethods; + +/// +/// One of the built-in JSON:API action methods on . +/// +internal sealed class BuiltinResourceActionMethod(JsonApiEndpoints endpoint, Type controllerType) + : ResourceActionMethod(controllerType) +{ + public JsonApiEndpoints Endpoint { get; } = endpoint; + + public override string ToString() + { + return Endpoint.ToString(); + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/CustomControllerActionMethod.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/CustomControllerActionMethod.cs deleted file mode 100644 index c5b89f27f1..0000000000 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/CustomControllerActionMethod.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.ActionMethods; - -/// -/// An action method in a custom controller, unrelated to JSON:API. -/// -internal sealed class CustomControllerActionMethod : OpenApiActionMethod -{ - public static CustomControllerActionMethod Instance { get; } = new(); - - private CustomControllerActionMethod() - { - } -} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/CustomJsonApiActionMethod.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/CustomJsonApiActionMethod.cs deleted file mode 100644 index 194d623d94..0000000000 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/CustomJsonApiActionMethod.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCore.Controllers; - -namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.ActionMethods; - -/// -/// A custom action method on . -/// -internal sealed class CustomJsonApiActionMethod : OpenApiActionMethod -{ - public static CustomJsonApiActionMethod Instance { get; } = new(); - - private CustomJsonApiActionMethod() - { - } -} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/CustomResourceActionMethod.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/CustomResourceActionMethod.cs new file mode 100644 index 0000000000..207c90f671 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/CustomResourceActionMethod.cs @@ -0,0 +1,20 @@ +using JsonApiDotNetCore.Controllers; +using Microsoft.AspNetCore.Mvc.Abstractions; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.ActionMethods; + +/// +/// A custom JSON:API action method on . +/// +internal sealed class CustomResourceActionMethod : ResourceActionMethod +{ + public ActionDescriptor Descriptor { get; } + + public CustomResourceActionMethod(ActionDescriptor descriptor, Type controllerType) + : base(controllerType) + { + ArgumentNullException.ThrowIfNull(descriptor); + + Descriptor = descriptor; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/JsonApiActionMethod.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/JsonApiActionMethod.cs index 3cbfeff775..fa6d9df921 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/JsonApiActionMethod.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/JsonApiActionMethod.cs @@ -1,17 +1,74 @@ +using System.Reflection; using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Middleware; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Routing; namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.ActionMethods; /// -/// One of the built-in JSON:API action methods on . +/// A JSON:API controller-based action method. /// -internal sealed class JsonApiActionMethod(JsonApiEndpoints endpoint, Type controllerType) - : BuiltinJsonApiActionMethod(controllerType) +internal abstract class JsonApiActionMethod { - public JsonApiEndpoints Endpoint { get; } = endpoint; + public Type ControllerType { get; } - public override string ToString() + protected JsonApiActionMethod(Type controllerType) { - return Endpoint.ToString(); + ArgumentNullException.ThrowIfNull(controllerType); + + ControllerType = controllerType; + } + + public static JsonApiActionMethod? TryCreate(ActionDescriptor descriptor) + { + ArgumentNullException.ThrowIfNull(descriptor); + + MethodInfo? actionMethod = descriptor.TryGetActionMethod(); + + if (actionMethod != null) + { + if (IsJsonApiController(actionMethod)) + { + Type? controllerType = actionMethod.ReflectedType; + ConsistencyGuard.ThrowIf(controllerType == null); + + if (IsAtomicOperationsController(actionMethod)) + { + var httpPostAttribute = actionMethod.GetCustomAttribute(true); + + if (httpPostAttribute != null) + { + return new OperationsActionMethod(controllerType); + } + } + else + { + IEnumerable httpMethodAttributes = actionMethod.GetCustomAttributes(true); + JsonApiEndpoints endpoint = httpMethodAttributes.GetJsonApiEndpoint(); + + if (endpoint != JsonApiEndpoints.None) + { + return new BuiltinResourceActionMethod(endpoint, controllerType); + } + } + + return new CustomResourceActionMethod(descriptor, controllerType); + } + } + + // An action method in a custom controller or a Minimal API endpoint, unrelated to JSON:API. + return null; + } + + private static bool IsJsonApiController(MethodInfo controllerAction) + { + return typeof(CoreJsonApiController).IsAssignableFrom(controllerAction.ReflectedType); + } + + private static bool IsAtomicOperationsController(MethodInfo controllerAction) + { + return typeof(BaseJsonApiOperationsController).IsAssignableFrom(controllerAction.ReflectedType); } } diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/OpenApiActionMethod.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/OpenApiActionMethod.cs deleted file mode 100644 index f8519f414a..0000000000 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/OpenApiActionMethod.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System.Reflection; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Middleware; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Abstractions; -using Microsoft.AspNetCore.Mvc.Routing; - -namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.ActionMethods; - -internal abstract class OpenApiActionMethod -{ - public static OpenApiActionMethod Create(ActionDescriptor descriptor) - { - ArgumentNullException.ThrowIfNull(descriptor); - - MethodInfo actionMethod = descriptor.GetActionMethod(); - - if (IsJsonApiController(actionMethod)) - { - Type? controllerType = actionMethod.ReflectedType; - ConsistencyGuard.ThrowIf(controllerType == null); - - if (IsAtomicOperationsController(actionMethod)) - { - var httpPostAttribute = actionMethod.GetCustomAttribute(true); - - if (httpPostAttribute != null) - { - return new AtomicOperationsActionMethod(controllerType); - } - } - else - { - IEnumerable httpMethodAttributes = actionMethod.GetCustomAttributes(true); - JsonApiEndpoints endpoint = httpMethodAttributes.GetJsonApiEndpoint(); - - if (endpoint != JsonApiEndpoints.None) - { - return new JsonApiActionMethod(endpoint, controllerType); - } - } - - return CustomJsonApiActionMethod.Instance; - } - - return CustomControllerActionMethod.Instance; - } - - private static bool IsJsonApiController(MethodInfo controllerAction) - { - return typeof(CoreJsonApiController).IsAssignableFrom(controllerAction.ReflectedType); - } - - private static bool IsAtomicOperationsController(MethodInfo controllerAction) - { - return typeof(BaseJsonApiOperationsController).IsAssignableFrom(controllerAction.ReflectedType); - } -} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/OperationsActionMethod.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/OperationsActionMethod.cs new file mode 100644 index 0000000000..65bdef3b7a --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/OperationsActionMethod.cs @@ -0,0 +1,9 @@ +using JsonApiDotNetCore.Controllers; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.ActionMethods; + +/// +/// The built-in JSON:API atomic:operations action method . +/// +internal sealed class OperationsActionMethod(Type controllerType) + : JsonApiActionMethod(controllerType); diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/ResourceActionMethod.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/ResourceActionMethod.cs new file mode 100644 index 0000000000..dc604d3f86 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/ActionMethods/ResourceActionMethod.cs @@ -0,0 +1,9 @@ +using JsonApiDotNetCore.Controllers; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.ActionMethods; + +/// +/// A resource-specific JSON:API action method on . +/// +internal abstract class ResourceActionMethod(Type controllerType) + : JsonApiActionMethod(controllerType); diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/AtomicOperationsResponseMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/AtomicOperationsResponseMetadata.cs index f259b76fb4..d19ea100ae 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/AtomicOperationsResponseMetadata.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/AtomicOperationsResponseMetadata.cs @@ -1,3 +1,4 @@ +using System.Net; using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents; namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.Documents; @@ -8,6 +9,22 @@ internal sealed class AtomicOperationsResponseMetadata : IJsonApiResponseMetadat public Type DocumentType => typeof(OperationsResponseDocument); + public IReadOnlyCollection SuccessStatusCodes { get; } = + [ + HttpStatusCode.OK, + HttpStatusCode.NoContent + ]; + + public IReadOnlyCollection ErrorStatusCodes { get; } = + [ + HttpStatusCode.BadRequest, + // Forbidden doesn't depend on whether ClientIdGeneration is enabled, because it is also used when an operation is not accessible. + HttpStatusCode.Forbidden, + HttpStatusCode.NotFound, + HttpStatusCode.Conflict, + HttpStatusCode.UnprocessableEntity + ]; + private AtomicOperationsResponseMetadata() { } diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/EmptyRelationshipResponseMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/EmptyRelationshipResponseMetadata.cs index 3cc784f9c4..8fbbd5b3e9 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/EmptyRelationshipResponseMetadata.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/EmptyRelationshipResponseMetadata.cs @@ -1,3 +1,4 @@ +using System.Net; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.Documents; @@ -6,10 +7,18 @@ internal sealed class EmptyRelationshipResponseMetadata : IJsonApiResponseMetada { public IReadOnlyCollection Relationships { get; } - public EmptyRelationshipResponseMetadata(IReadOnlyCollection relationships) + public IReadOnlyCollection SuccessStatusCodes { get; } + public IReadOnlyCollection ErrorStatusCodes { get; } + + public EmptyRelationshipResponseMetadata(IReadOnlyCollection relationships, IReadOnlyCollection successStatusCodes, + IReadOnlyCollection errorStatusCodes) { ArgumentNullException.ThrowIfNull(relationships); + ArgumentNullException.ThrowIfNull(successStatusCodes); + ArgumentNullException.ThrowIfNull(errorStatusCodes); Relationships = relationships; + SuccessStatusCodes = successStatusCodes; + ErrorStatusCodes = errorStatusCodes; } } diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/NonPrimaryResponseMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/NonPrimaryResponseMetadata.cs index 3a4b7ad432..f174afff07 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/NonPrimaryResponseMetadata.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/NonPrimaryResponseMetadata.cs @@ -1,3 +1,4 @@ +using System.Net; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.Documents; @@ -6,10 +7,18 @@ internal class NonPrimaryResponseMetadata : IJsonApiResponseMetadata { public IReadOnlyDictionary DocumentTypesByRelationship { get; } - protected NonPrimaryResponseMetadata(IReadOnlyDictionary documentTypesByRelationship) + public IReadOnlyCollection SuccessStatusCodes { get; } + public IReadOnlyCollection ErrorStatusCodes { get; } + + protected NonPrimaryResponseMetadata(IReadOnlyDictionary documentTypesByRelationship, + IReadOnlyCollection successStatusCodes, IReadOnlyCollection errorStatusCodes) { ArgumentNullException.ThrowIfNull(documentTypesByRelationship); + ArgumentNullException.ThrowIfNull(successStatusCodes); + ArgumentNullException.ThrowIfNull(errorStatusCodes); DocumentTypesByRelationship = documentTypesByRelationship; + SuccessStatusCodes = successStatusCodes; + ErrorStatusCodes = errorStatusCodes; } } diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/PrimaryResponseMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/PrimaryResponseMetadata.cs index af0761be28..8711e0e65e 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/PrimaryResponseMetadata.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/PrimaryResponseMetadata.cs @@ -1,6 +1,21 @@ +using System.Net; + namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.Documents; -internal sealed class PrimaryResponseMetadata(Type? documentType) : IJsonApiResponseMetadata +internal sealed class PrimaryResponseMetadata : IJsonApiResponseMetadata { - public Type? DocumentType { get; } = documentType; + public Type? DocumentType { get; } + public IReadOnlyCollection SuccessStatusCodes { get; } + public IReadOnlyCollection ErrorStatusCodes { get; } + + public PrimaryResponseMetadata(Type? documentType, IReadOnlyCollection successStatusCodes, + IReadOnlyCollection errorStatusCodes) + { + ArgumentNullException.ThrowIfNull(successStatusCodes); + ArgumentNullException.ThrowIfNull(errorStatusCodes); + + DocumentType = documentType; + SuccessStatusCodes = successStatusCodes; + ErrorStatusCodes = errorStatusCodes; + } } diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/RelationshipResponseMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/RelationshipResponseMetadata.cs index 14d43cd44e..9e90a73f71 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/RelationshipResponseMetadata.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/RelationshipResponseMetadata.cs @@ -1,6 +1,9 @@ +using System.Net; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.Documents; -internal sealed class RelationshipResponseMetadata(IReadOnlyDictionary documentTypesByRelationship) - : NonPrimaryResponseMetadata(documentTypesByRelationship); +internal sealed class RelationshipResponseMetadata( + IReadOnlyDictionary documentTypesByRelationship, IReadOnlyCollection successStatusCodes, + IReadOnlyCollection errorStatusCodes) + : NonPrimaryResponseMetadata(documentTypesByRelationship, successStatusCodes, errorStatusCodes); diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/SecondaryResponseMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/SecondaryResponseMetadata.cs index 47349ce44e..a20c9f700e 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/SecondaryResponseMetadata.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/Documents/SecondaryResponseMetadata.cs @@ -1,6 +1,9 @@ +using System.Net; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.Documents; -internal sealed class SecondaryResponseMetadata(IReadOnlyDictionary documentTypesByRelationship) - : NonPrimaryResponseMetadata(documentTypesByRelationship); +internal sealed class SecondaryResponseMetadata( + IReadOnlyDictionary documentTypesByRelationship, IReadOnlyCollection successStatusCodes, + IReadOnlyCollection errorStatusCodes) + : NonPrimaryResponseMetadata(documentTypesByRelationship, successStatusCodes, errorStatusCodes); diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs index 7de84f0345..eceb8206b3 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs @@ -1,3 +1,6 @@ +using System.Collections.ObjectModel; +using System.Net; +using System.Reflection; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Middleware; @@ -5,23 +8,34 @@ using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.Documents; using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents; using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Routing; namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata; /// -/// Provides JsonApiDotNetCore related metadata for an ASP.NET action method that can only be computed from the at runtime. +/// Provides JSON:API metadata for an ASP.NET action method that is computed from the . /// internal sealed class JsonApiEndpointMetadataProvider { + private readonly IJsonApiOptions _options; + private readonly IResourceGraph _resourceGraph; private readonly IControllerResourceMapping _controllerResourceMapping; private readonly NonPrimaryDocumentTypeFactory _nonPrimaryDocumentTypeFactory; - public JsonApiEndpointMetadataProvider(IControllerResourceMapping controllerResourceMapping, NonPrimaryDocumentTypeFactory nonPrimaryDocumentTypeFactory) + public JsonApiEndpointMetadataProvider(IJsonApiOptions options, IResourceGraph resourceGraph, IControllerResourceMapping controllerResourceMapping, + NonPrimaryDocumentTypeFactory nonPrimaryDocumentTypeFactory) { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(resourceGraph); ArgumentNullException.ThrowIfNull(controllerResourceMapping); ArgumentNullException.ThrowIfNull(nonPrimaryDocumentTypeFactory); + _options = options; + _resourceGraph = resourceGraph; _controllerResourceMapping = controllerResourceMapping; _nonPrimaryDocumentTypeFactory = nonPrimaryDocumentTypeFactory; } @@ -30,26 +44,40 @@ public JsonApiEndpointMetadata Get(ActionDescriptor descriptor) { ArgumentNullException.ThrowIfNull(descriptor); - var actionMethod = OpenApiActionMethod.Create(descriptor); + var actionMethod = JsonApiActionMethod.TryCreate(descriptor); JsonApiEndpointMetadata? metadata = null; switch (actionMethod) { - case AtomicOperationsActionMethod: + case OperationsActionMethod: { metadata = new JsonApiEndpointMetadata(AtomicOperationsRequestMetadata.Instance, AtomicOperationsResponseMetadata.Instance); break; } - case JsonApiActionMethod jsonApiActionMethod: + case BuiltinResourceActionMethod builtinJsonApiActionMethod: { - ResourceType? primaryResourceType = _controllerResourceMapping.GetResourceTypeForController(jsonApiActionMethod.ControllerType); - ConsistencyGuard.ThrowIf(primaryResourceType == null); + JsonApiEndpoints endpoint = builtinJsonApiActionMethod.Endpoint; + + ResourceType? resourceType = _controllerResourceMapping.GetResourceTypeForController(builtinJsonApiActionMethod.ControllerType); + ConsistencyGuard.ThrowIf(resourceType == null); + + ReadOnlyCollection successStatusCodes = GetSuccessStatusCodesForActionMethod(builtinJsonApiActionMethod); + ReadOnlyCollection errorStatusCodes = GetErrorStatusCodesForActionMethod(builtinJsonApiActionMethod, resourceType); + + IJsonApiRequestMetadata? requestMetadata = GetRequestMetadata(endpoint, resourceType); + IJsonApiResponseMetadata? responseMetadata = GetResponseMetadata(endpoint, resourceType, successStatusCodes, errorStatusCodes); - IJsonApiRequestMetadata? requestMetadata = GetRequestMetadata(jsonApiActionMethod.Endpoint, primaryResourceType); - IJsonApiResponseMetadata? responseMetadata = GetResponseMetadata(jsonApiActionMethod.Endpoint, primaryResourceType); metadata = new JsonApiEndpointMetadata(requestMetadata, responseMetadata); break; } + case CustomResourceActionMethod customResourceActionMethod: + { + ResourceType? resourceType = _controllerResourceMapping.GetResourceTypeForController(customResourceActionMethod.ControllerType); + ConsistencyGuard.ThrowIf(resourceType == null); + + metadata = GetCustomMetadata(customResourceActionMethod.Descriptor, resourceType); + break; + } } ConsistencyGuard.ThrowIf(metadata == null); @@ -92,56 +120,254 @@ private RelationshipRequestMetadata GetRelationshipRequestMetadata(IReadOnlyColl return new RelationshipRequestMetadata(documentTypesByRelationship.AsReadOnly()); } - private IJsonApiResponseMetadata? GetResponseMetadata(JsonApiEndpoints endpoint, ResourceType primaryResourceType) + private IJsonApiResponseMetadata? GetResponseMetadata(JsonApiEndpoints endpoint, ResourceType primaryResourceType, + ReadOnlyCollection successStatusCodes, ReadOnlyCollection errorStatusCodes) { return endpoint switch { - JsonApiEndpoints.GetCollection or JsonApiEndpoints.GetSingle or JsonApiEndpoints.Post or JsonApiEndpoints.Patch => GetPrimaryResponseMetadata( - primaryResourceType.ClrType, endpoint == JsonApiEndpoints.GetCollection), - JsonApiEndpoints.Delete => GetEmptyPrimaryResponseMetadata(), - JsonApiEndpoints.GetSecondary => GetSecondaryResponseMetadata(primaryResourceType.Relationships), - JsonApiEndpoints.GetRelationship => GetRelationshipResponseMetadata(primaryResourceType.Relationships), + JsonApiEndpoints.GetCollection => GetPrimaryResponseMetadata(primaryResourceType.ClrType, true, successStatusCodes, errorStatusCodes), + JsonApiEndpoints.GetSingle or JsonApiEndpoints.Post or JsonApiEndpoints.Patch => GetPrimaryResponseMetadata(primaryResourceType.ClrType, false, + successStatusCodes, errorStatusCodes), + JsonApiEndpoints.Delete => GetEmptyPrimaryResponseMetadata(successStatusCodes, errorStatusCodes), + JsonApiEndpoints.GetSecondary => GetSecondaryResponseMetadata(primaryResourceType.Relationships, successStatusCodes, errorStatusCodes), + JsonApiEndpoints.GetRelationship => GetRelationshipResponseMetadata(primaryResourceType.Relationships, false, successStatusCodes, errorStatusCodes), + JsonApiEndpoints.PatchRelationship => GetEmptyRelationshipResponseMetadata(primaryResourceType.Relationships, false, successStatusCodes, + errorStatusCodes), + JsonApiEndpoints.PostRelationship or JsonApiEndpoints.DeleteRelationship => GetEmptyRelationshipResponseMetadata(primaryResourceType.Relationships, + true, successStatusCodes, errorStatusCodes), + _ => null + }; + } + + private static ReadOnlyCollection GetSuccessStatusCodesForActionMethod(BuiltinResourceActionMethod actionMethod) + { + HttpStatusCode[]? statusCodes = actionMethod.Endpoint switch + { + JsonApiEndpoints.GetCollection or JsonApiEndpoints.GetSingle or JsonApiEndpoints.GetSecondary or JsonApiEndpoints.GetRelationship => + [ + HttpStatusCode.OK, + HttpStatusCode.NotModified + ], + JsonApiEndpoints.Post => + [ + HttpStatusCode.Created, + HttpStatusCode.NoContent + ], + JsonApiEndpoints.Patch => + [ + HttpStatusCode.OK, + HttpStatusCode.NoContent + ], + JsonApiEndpoints.Delete or JsonApiEndpoints.PostRelationship or JsonApiEndpoints.PatchRelationship or JsonApiEndpoints.DeleteRelationship => + [ + HttpStatusCode.NoContent + ], + _ => null + }; + + ConsistencyGuard.ThrowIf(statusCodes == null); + return statusCodes.AsReadOnly(); + } + + private ReadOnlyCollection GetErrorStatusCodesForActionMethod(BuiltinResourceActionMethod actionMethod, ResourceType resourceType) + { + // This condition doesn't apply to atomic operations, because Forbidden is also used when an operation is not accessible. + ClientIdGenerationMode clientIdGeneration = resourceType.ClientIdGeneration ?? _options.ClientIdGeneration; + + HttpStatusCode[]? statusCodes = actionMethod.Endpoint switch + { + JsonApiEndpoints.GetCollection => [HttpStatusCode.BadRequest], + JsonApiEndpoints.GetSingle or JsonApiEndpoints.GetSecondary or JsonApiEndpoints.GetRelationship => + [ + HttpStatusCode.BadRequest, + HttpStatusCode.NotFound + ], + JsonApiEndpoints.Post when clientIdGeneration == ClientIdGenerationMode.Forbidden => + [ + HttpStatusCode.BadRequest, + HttpStatusCode.Forbidden, + HttpStatusCode.NotFound, + HttpStatusCode.Conflict, + HttpStatusCode.UnprocessableEntity + ], + JsonApiEndpoints.Post or JsonApiEndpoints.Patch => + [ + HttpStatusCode.BadRequest, + HttpStatusCode.NotFound, + HttpStatusCode.Conflict, + HttpStatusCode.UnprocessableEntity + ], + JsonApiEndpoints.Delete => [HttpStatusCode.NotFound], JsonApiEndpoints.PostRelationship or JsonApiEndpoints.PatchRelationship or JsonApiEndpoints.DeleteRelationship => - GetEmptyRelationshipResponseMetadata(primaryResourceType.Relationships, endpoint != JsonApiEndpoints.PatchRelationship), + [ + HttpStatusCode.BadRequest, + HttpStatusCode.NotFound, + HttpStatusCode.Conflict, + HttpStatusCode.UnprocessableEntity + ], _ => null }; + + ConsistencyGuard.ThrowIf(statusCodes == null); + return statusCodes.AsReadOnly(); } - private static PrimaryResponseMetadata GetEmptyPrimaryResponseMetadata() + private static PrimaryResponseMetadata GetEmptyPrimaryResponseMetadata(ReadOnlyCollection successStatusCodes, + ReadOnlyCollection errorStatusCodes) { - return new PrimaryResponseMetadata(null); + return new PrimaryResponseMetadata(null, successStatusCodes, errorStatusCodes); } - private static PrimaryResponseMetadata GetPrimaryResponseMetadata(Type resourceClrType, bool endpointReturnsCollection) + private static PrimaryResponseMetadata GetPrimaryResponseMetadata(Type resourceClrType, bool endpointReturnsCollection, + ReadOnlyCollection successStatusCodes, ReadOnlyCollection errorStatusCodes) { Type documentOpenType = endpointReturnsCollection ? typeof(CollectionResponseDocument<>) : typeof(PrimaryResponseDocument<>); Type documentType = documentOpenType.MakeGenericType(resourceClrType); - return new PrimaryResponseMetadata(documentType); + return new PrimaryResponseMetadata(documentType, successStatusCodes, errorStatusCodes); } - private SecondaryResponseMetadata GetSecondaryResponseMetadata(IEnumerable relationships) + private SecondaryResponseMetadata GetSecondaryResponseMetadata(IEnumerable relationships, + ReadOnlyCollection successStatusCodes, ReadOnlyCollection errorStatusCodes) { Dictionary documentTypesByRelationship = relationships.ToDictionary(relationship => relationship, _nonPrimaryDocumentTypeFactory.GetForSecondaryResponse); - return new SecondaryResponseMetadata(documentTypesByRelationship.AsReadOnly()); + return new SecondaryResponseMetadata(documentTypesByRelationship.AsReadOnly(), successStatusCodes, errorStatusCodes); } - private RelationshipResponseMetadata GetRelationshipResponseMetadata(IReadOnlyCollection relationships) + private RelationshipResponseMetadata GetRelationshipResponseMetadata(IReadOnlyCollection relationships, + bool ignoreHasOneRelationships, ReadOnlyCollection successStatusCodes, ReadOnlyCollection errorStatusCodes) { - Dictionary documentTypesByRelationship = relationships.ToDictionary(relationship => relationship, + IReadOnlyCollection relationshipsOfEndpoint = + ignoreHasOneRelationships ? relationships.OfType().ToList().AsReadOnly() : relationships; + + Dictionary documentTypesByRelationship = relationshipsOfEndpoint.ToDictionary(relationship => relationship, _nonPrimaryDocumentTypeFactory.GetForRelationshipResponse); - return new RelationshipResponseMetadata(documentTypesByRelationship.AsReadOnly()); + return new RelationshipResponseMetadata(documentTypesByRelationship.AsReadOnly(), successStatusCodes, errorStatusCodes); } private static EmptyRelationshipResponseMetadata GetEmptyRelationshipResponseMetadata(IReadOnlyCollection relationships, - bool ignoreHasOneRelationships) + bool ignoreHasOneRelationships, ReadOnlyCollection successStatusCodes, ReadOnlyCollection errorStatusCodes) { IReadOnlyCollection relationshipsOfEndpoint = ignoreHasOneRelationships ? relationships.OfType().ToList().AsReadOnly() : relationships; - return new EmptyRelationshipResponseMetadata(relationshipsOfEndpoint); + return new EmptyRelationshipResponseMetadata(relationshipsOfEndpoint, successStatusCodes, errorStatusCodes); + } + + private JsonApiEndpointMetadata GetCustomMetadata(ActionDescriptor descriptor, ResourceType controllerResourceType) + { + // The heuristics used here are kinda arbitrary, because we can't really know the purpose of a custom action method, while we do need + // to choose JSON:API request/response types. Please open an issue describing your action method signature(s) if this doesn't meet your needs. + + bool hasParameterForId = false; + bool hasParameterForRelationshipName = false; + bool hasRelationshipsInRoute = false; + + if (descriptor is ControllerActionDescriptor { AttributeRouteInfo.Template: not null } controllerActionDescriptor) + { +#pragma warning disable CS8602 // Dereference of a possibly null reference. + // Justification: Bug in the C# compiler, similar to https://github.com/dotnet/roslyn/issues/50162. + hasParameterForId = controllerActionDescriptor.AttributeRouteInfo.Template.Contains("{id}"); + hasParameterForRelationshipName = controllerActionDescriptor.AttributeRouteInfo.Template.Contains("{relationshipName}"); + hasRelationshipsInRoute = controllerActionDescriptor.AttributeRouteInfo.Template.Split('/').Contains("relationships"); +#pragma warning restore CS8602 // Dereference of a possibly null reference. + } + + MethodInfo? actionMethod = descriptor.TryGetActionMethod(); + ConsistencyGuard.ThrowIf(actionMethod == null); + + HashSet httpMethods = actionMethod.GetCustomAttributes(true).SelectMany(httpMethod => httpMethod.HttpMethods).ToHashSet(); + bool skipHasOneAtRelationshipEndpoint = httpMethods.Contains("POST") || httpMethods.Contains("DELETE"); + + IJsonApiRequestMetadata? requestMetadata = GetCustomRequestMetadata(descriptor, controllerResourceType, hasParameterForId, + hasParameterForRelationshipName, skipHasOneAtRelationshipEndpoint); + + IJsonApiResponseMetadata? responseMetadata = GetCustomResponseMetadata(descriptor, controllerResourceType, hasParameterForRelationshipName, + hasRelationshipsInRoute, skipHasOneAtRelationshipEndpoint); + + return new JsonApiEndpointMetadata(requestMetadata, responseMetadata); + } + + private IJsonApiRequestMetadata? GetCustomRequestMetadata(ActionDescriptor descriptor, ResourceType controllerResourceType, bool hasParameterForId, + bool hasParameterForRelationshipName, bool skipHasOneAtRelationshipEndpoint) + { + ConsumesAttribute? consumes = descriptor.FilterDescriptors.Select(filter => filter.Filter).OfType().FirstOrDefault(); + + if (consumes != null) + { + Type? endpointResourceClrType = ((IAcceptsMetadata)consumes).RequestType; + ResourceType? endpointResourceType = endpointResourceClrType != null ? _resourceGraph.GetResourceType(endpointResourceClrType) : null; + ResourceType primaryResourceType = endpointResourceType ?? controllerResourceType; + + if (!hasParameterForRelationshipName) + { + return hasParameterForId + ? GetPatchResourceRequestMetadata(primaryResourceType.ClrType) + : GetPostResourceRequestMetadata(primaryResourceType.ClrType); + } + + return GetRelationshipRequestMetadata(primaryResourceType.Relationships, skipHasOneAtRelationshipEndpoint); + } + + return null; + } + + private IJsonApiResponseMetadata? GetCustomResponseMetadata(ActionDescriptor descriptor, ResourceType controllerResourceType, + bool hasParameterForRelationshipName, bool hasRelationshipsInRoute, bool skipHasOneAtRelationshipEndpoint) + { + ResourceType? successResponseBodyType = null; + bool isResponseBodyCollection = false; + HashSet successStatusCodeSet = []; + HashSet errorStatusCodeSet = []; + + foreach (ProducesResponseTypeAttribute produces in descriptor.FilterDescriptors.Select(filter => filter.Filter).OfType()) + { + bool isSuccessResponse = produces.StatusCode < 400; + + if (isSuccessResponse && produces.Type != typeof(void)) + { + Type? elementType = CollectionConverter.Instance.FindCollectionElementType(produces.Type); + successResponseBodyType = _resourceGraph.GetResourceType(elementType ?? produces.Type); + isResponseBodyCollection = elementType != null; + } + + if (isSuccessResponse) + { + successStatusCodeSet.Add((HttpStatusCode)produces.StatusCode); + } + else + { + errorStatusCodeSet.Add((HttpStatusCode)produces.StatusCode); + } + } + + ResourceType primaryResourceType = successResponseBodyType ?? controllerResourceType; + ReadOnlyCollection successStatusCodes = successStatusCodeSet.ToArray().AsReadOnly(); + ReadOnlyCollection errorStatusCodes = errorStatusCodeSet.ToArray().AsReadOnly(); + + if (!hasParameterForRelationshipName && !hasRelationshipsInRoute) + { + return successResponseBodyType != null + ? GetPrimaryResponseMetadata(successResponseBodyType.ClrType, isResponseBodyCollection, successStatusCodes, errorStatusCodes) + : GetEmptyPrimaryResponseMetadata(successStatusCodes, errorStatusCodes); + } + + if (hasParameterForRelationshipName && !hasRelationshipsInRoute) + { + return GetSecondaryResponseMetadata(primaryResourceType.Relationships, successStatusCodes, errorStatusCodes); + } + + if (hasParameterForRelationshipName && hasRelationshipsInRoute) + { + return successResponseBodyType != null + ? GetRelationshipResponseMetadata(primaryResourceType.Relationships, skipHasOneAtRelationshipEndpoint, successStatusCodes, errorStatusCodes) + : GetEmptyRelationshipResponseMetadata(primaryResourceType.Relationships, skipHasOneAtRelationshipEndpoint, successStatusCodes, + errorStatusCodes); + } + + return null; } } diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/MethodInfoWrapper.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/MethodInfoWrapper.cs new file mode 100644 index 0000000000..100024280a --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/MethodInfoWrapper.cs @@ -0,0 +1,168 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Reflection; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +/// +/// Used to hide custom attributes that are applied on a method from Swashbuckle. +/// +[ExcludeFromCodeCoverage] +internal sealed class MethodInfoWrapper : MethodInfo +{ + private readonly MethodInfo _innerMethod; + private readonly Type[] _attributeTypesToHide; + + public override IEnumerable CustomAttributes => GetCustomAttributesData(); + public override Type? DeclaringType => _innerMethod.DeclaringType; + public override bool IsCollectible => _innerMethod.IsCollectible; + public override int MetadataToken => _innerMethod.MetadataToken; + public override Module Module => _innerMethod.Module; + public override string Name => _innerMethod.Name; + public override Type? ReflectedType => _innerMethod.ReflectedType; + public override MethodAttributes Attributes => _innerMethod.Attributes; + public override CallingConventions CallingConvention => _innerMethod.CallingConvention; + public override bool ContainsGenericParameters => _innerMethod.ContainsGenericParameters; + public override bool IsConstructedGenericMethod => _innerMethod.IsConstructedGenericMethod; + public override bool IsGenericMethod => _innerMethod.IsGenericMethod; + public override bool IsGenericMethodDefinition => _innerMethod.IsGenericMethodDefinition; + public override bool IsSecurityCritical => _innerMethod.IsSecurityCritical; + public override bool IsSecuritySafeCritical => _innerMethod.IsSecuritySafeCritical; + public override bool IsSecurityTransparent => _innerMethod.IsSecurityTransparent; + public override RuntimeMethodHandle MethodHandle => _innerMethod.MethodHandle; + public override MethodImplAttributes MethodImplementationFlags => _innerMethod.MethodImplementationFlags; + public override MemberTypes MemberType => _innerMethod.MemberType; + public override ParameterInfo ReturnParameter => _innerMethod.ReturnParameter; + public override Type ReturnType => _innerMethod.ReturnType; + public override ICustomAttributeProvider ReturnTypeCustomAttributes => _innerMethod.ReturnTypeCustomAttributes; + + public MethodInfoWrapper(MethodInfo innerMethod, Type[] attributeTypesToHide) + { + ArgumentNullException.ThrowIfNull(innerMethod); + ArgumentGuard.NotNullNorEmpty(attributeTypesToHide); + + _innerMethod = innerMethod; + _attributeTypesToHide = attributeTypesToHide; + } + + public override object[] GetCustomAttributes(bool inherit) + { + List customAttributes = _innerMethod.GetCustomAttributes(inherit).ToList(); + +#pragma warning disable AV1530 // Loop variable should not be written to in loop body + for (int index = 0; index < customAttributes.Count; index++) + { + if (_attributeTypesToHide.Any(attribute => attribute.IsInstanceOfType(customAttributes[index]))) + { + customAttributes.RemoveAt(index); + index--; + } + } +#pragma warning restore AV1530 // Loop variable should not be written to in loop body + + return customAttributes.ToArray(); + } + + public override object[] GetCustomAttributes(Type attributeType, bool inherit) + { + List customAttributes = _innerMethod.GetCustomAttributes(attributeType, inherit).ToList(); + +#pragma warning disable AV1530 // Loop variable should not be written to in loop body + for (int index = 0; index < customAttributes.Count; index++) + { + if (_attributeTypesToHide.Any(attribute => attribute.IsInstanceOfType(customAttributes[index]))) + { + customAttributes.RemoveAt(index); + index--; + } + } +#pragma warning restore AV1530 // Loop variable should not be written to in loop body + + object[] typedArray = (object[])Array.CreateInstance(attributeType, customAttributes.Count); + + for (int index = 0; index < customAttributes.Count; index++) + { + typedArray[index] = customAttributes[index]; + } + + return typedArray; + } + + public override IList GetCustomAttributesData() + { + List customAttributes = _innerMethod.GetCustomAttributesData().ToList(); + +#pragma warning disable AV1530 // Loop variable should not be written to in loop body + for (int index = 0; index < customAttributes.Count; index++) + { + if (_attributeTypesToHide.Any(attribute => attribute.IsAssignableFrom(customAttributes[index].AttributeType))) + { + customAttributes.RemoveAt(index); + index--; + } + } +#pragma warning restore AV1530 // Loop variable should not be written to in loop body + + return customAttributes.ToArray(); + } + + public override bool HasSameMetadataDefinitionAs(MemberInfo other) + { + return _innerMethod.HasSameMetadataDefinitionAs(other); + } + + public override bool IsDefined(Type attributeType, bool inherit) + { + return _innerMethod.IsDefined(attributeType, inherit); + } + + public override MethodBody? GetMethodBody() + { + return _innerMethod.GetMethodBody(); + } + + public override MethodImplAttributes GetMethodImplementationFlags() + { + return _innerMethod.GetMethodImplementationFlags(); + } + + public override ParameterInfo[] GetParameters() + { + return _innerMethod.GetParameters(); + } + + public override object? Invoke(object? obj, BindingFlags invokeAttr, Binder? binder, object?[]? parameters, CultureInfo? culture) + { + return _innerMethod.Invoke(obj, invokeAttr, binder, parameters, culture); + } + + public override Delegate CreateDelegate(Type delegateType) + { + return _innerMethod.CreateDelegate(delegateType); + } + + public override Delegate CreateDelegate(Type delegateType, object? target) + { + return _innerMethod.CreateDelegate(delegateType, target); + } + + public override MethodInfo GetBaseDefinition() + { + return _innerMethod.GetBaseDefinition(); + } + + public override Type[] GetGenericArguments() + { + return _innerMethod.GetGenericArguments(); + } + + public override MethodInfo GetGenericMethodDefinition() + { + return _innerMethod.GetGenericMethodDefinition(); + } + + public override MethodInfo MakeGenericMethod(params Type[] typeArguments) + { + return _innerMethod.MakeGenericMethod(typeArguments); + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiOperationIdSelector.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiOperationIdSelector.cs index c59470f371..065e26941c 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiOperationIdSelector.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiOperationIdSelector.cs @@ -56,22 +56,17 @@ public string GetOpenApiOperationId(ApiDescription endpoint) { ArgumentNullException.ThrowIfNull(endpoint); - var actionMethod = OpenApiActionMethod.Create(endpoint.ActionDescriptor); + var actionMethod = JsonApiActionMethod.TryCreate(endpoint.ActionDescriptor); - switch (actionMethod) + if (actionMethod is not null and not CustomResourceActionMethod) { - case BuiltinJsonApiActionMethod builtinJsonApiActionMethod: - { - ResourceType? primaryResourceType = _controllerResourceMapping.GetResourceTypeForController(builtinJsonApiActionMethod.ControllerType); - - string template = GetTemplate(endpoint); - return ApplyTemplate(template, primaryResourceType, endpoint); - } - default: - { - return DefaultOperationIdSelector(endpoint); - } + ResourceType? primaryResourceType = _controllerResourceMapping.GetResourceTypeForController(actionMethod.ControllerType); + + string template = GetTemplate(endpoint); + return ApplyTemplate(template, primaryResourceType, endpoint); } + + return DefaultOperationIdSelector(endpoint); } private static string GetTemplate(ApiDescription endpoint) diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ParameterInfoWrapper.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ParameterInfoWrapper.cs index a785de0452..f8c68ea7bb 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ParameterInfoWrapper.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ParameterInfoWrapper.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Reflection; namespace JsonApiDotNetCore.OpenApi.Swashbuckle; @@ -6,6 +7,7 @@ namespace JsonApiDotNetCore.OpenApi.Swashbuckle; /// Used for parameters in action method expansion. Changes the parameter name and type, while still using all metadata of the underlying non-expanded /// parameter. /// +[ExcludeFromCodeCoverage] internal sealed class ParameterInfoWrapper : ParameterInfo { private readonly ParameterInfo _innerParameter; diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/GenerationCacheSchemaGenerator.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/GenerationCacheSchemaGenerator.cs index beb10f94bf..d67203979a 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/GenerationCacheSchemaGenerator.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/GenerationCacheSchemaGenerator.cs @@ -73,9 +73,9 @@ private bool EvaluateHasAtomicOperationsEndpoint() foreach (ActionDescriptor descriptor in descriptors) { - var actionMethod = OpenApiActionMethod.Create(descriptor); + var actionMethod = JsonApiActionMethod.TryCreate(descriptor); - if (actionMethod is AtomicOperationsActionMethod) + if (actionMethod is OperationsActionMethod) { return true; } diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/DocumentationOpenApiOperationFilter.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/DocumentationOpenApiOperationFilter.cs index 0d6f89b1b1..caeb40e96b 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/DocumentationOpenApiOperationFilter.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/DocumentationOpenApiOperationFilter.cs @@ -1,10 +1,10 @@ using System.Net; -using System.Reflection; using Humanizer; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata.ActionMethods; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.AspNetCore.Mvc.ApiExplorer; @@ -84,9 +84,15 @@ public void Apply(OpenApiOperation operation, OperationFilterContext context) } } - MethodInfo actionMethod = context.ApiDescription.ActionDescriptor.GetActionMethod(); + var actionMethod = JsonApiActionMethod.TryCreate(context.ApiDescription.ActionDescriptor); + + if (actionMethod is not OperationsActionMethod and not BuiltinResourceActionMethod) + { + return; + } + string actionName = context.MethodInfo.Name; - ResourceType? resourceType = _controllerResourceMapping.GetResourceTypeForController(actionMethod.ReflectedType); + ResourceType? resourceType = _controllerResourceMapping.GetResourceTypeForController(context.MethodInfo.ReflectedType); if (resourceType != null) { diff --git a/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/CoffeeSummaries/CoffeeSummariesRequestBuilder.cs b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/CoffeeSummaries/CoffeeSummariesRequestBuilder.cs new file mode 100644 index 0000000000..28a577bed7 --- /dev/null +++ b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/CoffeeSummaries/CoffeeSummariesRequestBuilder.cs @@ -0,0 +1,45 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions; +using OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CoffeeSummaries.Summary; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System; +namespace OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CoffeeSummaries +{ + /// + /// Builds and executes requests for operations under \coffeeSummaries + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class CoffeeSummariesRequestBuilder : BaseRequestBuilder + { + /// The summary property + public global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CoffeeSummaries.Summary.SummaryRequestBuilder Summary + { + get => new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CoffeeSummaries.Summary.SummaryRequestBuilder(PathParameters, RequestAdapter); + } + + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public CoffeeSummariesRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/coffeeSummaries", pathParameters) + { + } + + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public CoffeeSummariesRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/coffeeSummaries", rawUrl) + { + } + } +} +#pragma warning restore CS0618 diff --git a/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/CoffeeSummaries/Summary/SummaryRequestBuilder.cs b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/CoffeeSummaries/Summary/SummaryRequestBuilder.cs new file mode 100644 index 0000000000..53385f94bb --- /dev/null +++ b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/CoffeeSummaries/Summary/SummaryRequestBuilder.cs @@ -0,0 +1,105 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System; +namespace OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CoffeeSummaries.Summary +{ + /// + /// Builds and executes requests for operations under \coffeeSummaries\summary + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class SummaryRequestBuilder : BaseRequestBuilder + { + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public SummaryRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/coffeeSummaries/summary", pathParameters) + { + } + + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public SummaryRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/coffeeSummaries/summary", rawUrl) + { + } + + /// + /// Summarizes all cups of coffee, indicating their ingredients. + /// + /// A + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 404 status code + public async Task GetAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToGetRequestInformation(requestConfiguration); + var errorMapping = new Dictionary> + { + { "404", global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendAsync(requestInfo, global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.PrimaryCoffeeSummaryResponseDocument.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Summarizes all cups of coffee, indicating their ingredients. + /// + /// A + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + public async Task HeadAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToHeadRequestInformation(requestConfiguration); + return await RequestAdapter.SendPrimitiveAsync(requestInfo, default, cancellationToken).ConfigureAwait(false); + } + + /// + /// Summarizes all cups of coffee, indicating their ingredients. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToGetRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + return requestInfo; + } + + /// + /// Summarizes all cups of coffee, indicating their ingredients. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToHeadRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.HEAD, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + return requestInfo; + } + + /// + /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored. + /// + /// A + /// The raw URL to use for the request builder. + public global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CoffeeSummaries.Summary.SummaryRequestBuilder WithUrl(string rawUrl) + { + return new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CoffeeSummaries.Summary.SummaryRequestBuilder(rawUrl, RequestAdapter); + } + } +} +#pragma warning restore CS0618 diff --git a/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/CupOfCoffees/Batch/BatchRequestBuilder.cs b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/CupOfCoffees/Batch/BatchRequestBuilder.cs new file mode 100644 index 0000000000..8d7dd520d8 --- /dev/null +++ b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/CupOfCoffees/Batch/BatchRequestBuilder.cs @@ -0,0 +1,148 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System; +namespace OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CupOfCoffees.Batch +{ + /// + /// Builds and executes requests for operations under \cupOfCoffees\batch + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class BatchRequestBuilder : BaseRequestBuilder + { + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public BatchRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/cupOfCoffees/batch?size={size}", pathParameters) + { + } + + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public BatchRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/cupOfCoffees/batch?size={size}", rawUrl) + { + } + + /// + /// Deletes all cups of coffee. Returns 404 when none found. + /// + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 404 status code + public async Task DeleteAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToDeleteRequestInformation(requestConfiguration); + var errorMapping = new Dictionary> + { + { "404", global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + await RequestAdapter.SendNoContentAsync(requestInfo, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Resets all cups of coffee to black. + /// + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + public async Task PatchAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToPatchRequestInformation(requestConfiguration); + await RequestAdapter.SendNoContentAsync(requestInfo, default, cancellationToken).ConfigureAwait(false); + } + + /// + /// Creates cups of coffee in batch. + /// + /// The request body + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + public async Task PostAsync(global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.CreateCupOfCoffeeRequestDocument body, Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = ToPostRequestInformation(body, requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + await RequestAdapter.SendNoContentAsync(requestInfo, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Deletes all cups of coffee. Returns 404 when none found. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToDeleteRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.DELETE, "{+baseurl}/cupOfCoffees/batch", PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + return requestInfo; + } + + /// + /// Resets all cups of coffee to black. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToPatchRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.PATCH, "{+baseurl}/cupOfCoffees/batch", PathParameters); + requestInfo.Configure(requestConfiguration); + return requestInfo; + } + + /// + /// Creates cups of coffee in batch. + /// + /// A + /// The request body + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToPostRequestInformation(global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.CreateCupOfCoffeeRequestDocument body, Action>? requestConfiguration = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = new RequestInformation(Method.POST, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + requestInfo.SetContentFromParsable(RequestAdapter, "application/vnd.api+json;ext=openapi", body); + return requestInfo; + } + + /// + /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored. + /// + /// A + /// The raw URL to use for the request builder. + public global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CupOfCoffees.Batch.BatchRequestBuilder WithUrl(string rawUrl) + { + return new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CupOfCoffees.Batch.BatchRequestBuilder(rawUrl, RequestAdapter); + } + + /// + /// Creates cups of coffee in batch. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class BatchRequestBuilderPostQueryParameters + { + /// The batch size. + [QueryParameter("size")] + public int? Size { get; set; } + } + } +} +#pragma warning restore CS0618 diff --git a/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/CupOfCoffees/CupOfCoffeesRequestBuilder.cs b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/CupOfCoffees/CupOfCoffeesRequestBuilder.cs index 2f2b0d0fb2..af632772d1 100644 --- a/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/CupOfCoffees/CupOfCoffeesRequestBuilder.cs +++ b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/CupOfCoffees/CupOfCoffeesRequestBuilder.cs @@ -5,7 +5,9 @@ using Microsoft.Kiota.Abstractions.Extensions; using Microsoft.Kiota.Abstractions.Serialization; using Microsoft.Kiota.Abstractions; +using OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CupOfCoffees.Batch; using OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CupOfCoffees.Item; +using OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CupOfCoffees.OnlyBlack; using OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models; using System.Collections.Generic; using System.IO; @@ -20,6 +22,18 @@ namespace OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CupOfCoffees [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] public partial class CupOfCoffeesRequestBuilder : BaseRequestBuilder { + /// The batch property + public global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CupOfCoffees.Batch.BatchRequestBuilder Batch + { + get => new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CupOfCoffees.Batch.BatchRequestBuilder(PathParameters, RequestAdapter); + } + + /// The onlyBlack property + public global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CupOfCoffees.OnlyBlack.OnlyBlackRequestBuilder OnlyBlack + { + get => new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CupOfCoffees.OnlyBlack.OnlyBlackRequestBuilder(PathParameters, RequestAdapter); + } + /// Gets an item from the OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.cupOfCoffees.item collection /// The identifier of the cupOfCoffee to delete. /// A diff --git a/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/CupOfCoffees/OnlyBlack/Item/OnlyBlackItemRequestBuilder.cs b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/CupOfCoffees/OnlyBlack/Item/OnlyBlackItemRequestBuilder.cs new file mode 100644 index 0000000000..e79790ead3 --- /dev/null +++ b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/CupOfCoffees/OnlyBlack/Item/OnlyBlackItemRequestBuilder.cs @@ -0,0 +1,105 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System; +namespace OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CupOfCoffees.OnlyBlack.Item +{ + /// + /// Builds and executes requests for operations under \cupOfCoffees\onlyBlack\{id} + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class OnlyBlackItemRequestBuilder : BaseRequestBuilder + { + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public OnlyBlackItemRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/cupOfCoffees/onlyBlack/{id}", pathParameters) + { + } + + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public OnlyBlackItemRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/cupOfCoffees/onlyBlack/{id}", rawUrl) + { + } + + /// + /// Gets a cup of coffee by ID, if the cup is without sugar and milk. Returns 404 otherwise. + /// + /// A + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 404 status code + public async Task GetAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToGetRequestInformation(requestConfiguration); + var errorMapping = new Dictionary> + { + { "404", global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendAsync(requestInfo, global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.PrimaryCupOfCoffeeResponseDocument.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets a cup of coffee by ID, if the cup is without sugar and milk. Returns 404 otherwise. + /// + /// A + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + public async Task HeadAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToHeadRequestInformation(requestConfiguration); + return await RequestAdapter.SendPrimitiveAsync(requestInfo, default, cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets a cup of coffee by ID, if the cup is without sugar and milk. Returns 404 otherwise. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToGetRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + return requestInfo; + } + + /// + /// Gets a cup of coffee by ID, if the cup is without sugar and milk. Returns 404 otherwise. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToHeadRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.HEAD, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + return requestInfo; + } + + /// + /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored. + /// + /// A + /// The raw URL to use for the request builder. + public global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CupOfCoffees.OnlyBlack.Item.OnlyBlackItemRequestBuilder WithUrl(string rawUrl) + { + return new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CupOfCoffees.OnlyBlack.Item.OnlyBlackItemRequestBuilder(rawUrl, RequestAdapter); + } + } +} +#pragma warning restore CS0618 diff --git a/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/CupOfCoffees/OnlyBlack/OnlyBlackRequestBuilder.cs b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/CupOfCoffees/OnlyBlack/OnlyBlackRequestBuilder.cs new file mode 100644 index 0000000000..6f3989a5ba --- /dev/null +++ b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/CupOfCoffees/OnlyBlack/OnlyBlackRequestBuilder.cs @@ -0,0 +1,114 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CupOfCoffees.OnlyBlack.Item; +using OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System; +namespace OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CupOfCoffees.OnlyBlack +{ + /// + /// Builds and executes requests for operations under \cupOfCoffees\onlyBlack + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class OnlyBlackRequestBuilder : BaseRequestBuilder + { + /// Gets an item from the OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.cupOfCoffees.onlyBlack.item collection + /// Unique identifier of the item + /// A + public global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CupOfCoffees.OnlyBlack.Item.OnlyBlackItemRequestBuilder this[string position] + { + get + { + var urlTplParams = new Dictionary(PathParameters); + urlTplParams.Add("id", position); + return new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CupOfCoffees.OnlyBlack.Item.OnlyBlackItemRequestBuilder(urlTplParams, RequestAdapter); + } + } + + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public OnlyBlackRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/cupOfCoffees/onlyBlack", pathParameters) + { + } + + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public OnlyBlackRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/cupOfCoffees/onlyBlack", rawUrl) + { + } + + /// + /// Gets all cups of coffee without sugar and milk. + /// + /// A + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + public async Task GetAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToGetRequestInformation(requestConfiguration); + return await RequestAdapter.SendAsync(requestInfo, global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.CupOfCoffeeCollectionResponseDocument.CreateFromDiscriminatorValue, default, cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets all cups of coffee without sugar and milk. + /// + /// A + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + public async Task HeadAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToHeadRequestInformation(requestConfiguration); + return await RequestAdapter.SendPrimitiveAsync(requestInfo, default, cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets all cups of coffee without sugar and milk. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToGetRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + return requestInfo; + } + + /// + /// Gets all cups of coffee without sugar and milk. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToHeadRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.HEAD, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + return requestInfo; + } + + /// + /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored. + /// + /// A + /// The raw URL to use for the request builder. + public global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CupOfCoffees.OnlyBlack.OnlyBlackRequestBuilder WithUrl(string rawUrl) + { + return new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CupOfCoffees.OnlyBlack.OnlyBlackRequestBuilder(rawUrl, RequestAdapter); + } + } +} +#pragma warning restore CS0618 diff --git a/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/MixedControllersClient.cs b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/MixedControllersClient.cs index 1074664525..c2b2d21e14 100644 --- a/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/MixedControllersClient.cs +++ b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/MixedControllersClient.cs @@ -9,6 +9,7 @@ using Microsoft.Kiota.Serialization.Json; using Microsoft.Kiota.Serialization.Multipart; using Microsoft.Kiota.Serialization.Text; +using OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CoffeeSummaries; using OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CupOfCoffees; using OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Emails; using OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.FileTransfers; @@ -24,6 +25,12 @@ namespace OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] public partial class MixedControllersClient : BaseRequestBuilder { + /// The coffeeSummaries property + public global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CoffeeSummaries.CoffeeSummariesRequestBuilder CoffeeSummaries + { + get => new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CoffeeSummaries.CoffeeSummariesRequestBuilder(PathParameters, RequestAdapter); + } + /// The cupOfCoffees property public global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.CupOfCoffees.CupOfCoffeesRequestBuilder CupOfCoffees { diff --git a/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/AttributesInCoffeeSummaryResponse.cs b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/AttributesInCoffeeSummaryResponse.cs new file mode 100644 index 0000000000..96565cbf7f --- /dev/null +++ b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/AttributesInCoffeeSummaryResponse.cs @@ -0,0 +1,95 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class AttributesInCoffeeSummaryResponse : global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.AttributesInResponse, IParsable + #pragma warning restore CS1591 + { + /// The blackCount property + public int? BlackCount + { + get { return BackingStore?.Get("blackCount"); } + set { BackingStore?.Set("blackCount", value); } + } + + /// The onlyMilkCount property + public int? OnlyMilkCount + { + get { return BackingStore?.Get("onlyMilkCount"); } + set { BackingStore?.Set("onlyMilkCount", value); } + } + + /// The onlySugarCount property + public int? OnlySugarCount + { + get { return BackingStore?.Get("onlySugarCount"); } + set { BackingStore?.Set("onlySugarCount", value); } + } + + /// The sugarWithMilkCount property + public int? SugarWithMilkCount + { + get { return BackingStore?.Get("sugarWithMilkCount"); } + set { BackingStore?.Set("sugarWithMilkCount", value); } + } + + /// The totalCount property + public int? TotalCount + { + get { return BackingStore?.Get("totalCount"); } + set { BackingStore?.Set("totalCount", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.AttributesInCoffeeSummaryResponse CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.AttributesInCoffeeSummaryResponse(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "blackCount", n => { BlackCount = n.GetIntValue(); } }, + { "onlyMilkCount", n => { OnlyMilkCount = n.GetIntValue(); } }, + { "onlySugarCount", n => { OnlySugarCount = n.GetIntValue(); } }, + { "sugarWithMilkCount", n => { SugarWithMilkCount = n.GetIntValue(); } }, + { "totalCount", n => { TotalCount = n.GetIntValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteIntValue("blackCount", BlackCount); + writer.WriteIntValue("onlyMilkCount", OnlyMilkCount); + writer.WriteIntValue("onlySugarCount", OnlySugarCount); + writer.WriteIntValue("sugarWithMilkCount", SugarWithMilkCount); + writer.WriteIntValue("totalCount", TotalCount); + } + } +} +#pragma warning restore CS0618 diff --git a/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/AttributesInCreateCupOfCoffeeRequest.cs b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/AttributesInCreateCupOfCoffeeRequest.cs new file mode 100644 index 0000000000..63a2f2d7fa --- /dev/null +++ b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/AttributesInCreateCupOfCoffeeRequest.cs @@ -0,0 +1,68 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class AttributesInCreateCupOfCoffeeRequest : global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.AttributesInCreateRequest, IParsable + #pragma warning restore CS1591 + { + /// The hasMilk property + public bool? HasMilk + { + get { return BackingStore?.Get("hasMilk"); } + set { BackingStore?.Set("hasMilk", value); } + } + + /// The hasSugar property + public bool? HasSugar + { + get { return BackingStore?.Get("hasSugar"); } + set { BackingStore?.Set("hasSugar", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.AttributesInCreateCupOfCoffeeRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.AttributesInCreateCupOfCoffeeRequest(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "hasMilk", n => { HasMilk = n.GetBoolValue(); } }, + { "hasSugar", n => { HasSugar = n.GetBoolValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteBoolValue("hasMilk", HasMilk); + writer.WriteBoolValue("hasSugar", HasSugar); + } + } +} +#pragma warning restore CS0618 diff --git a/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/AttributesInCreateRequest.cs b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/AttributesInCreateRequest.cs new file mode 100644 index 0000000000..969c0e96f9 --- /dev/null +++ b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/AttributesInCreateRequest.cs @@ -0,0 +1,75 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class AttributesInCreateRequest : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The openapiDiscriminator property + public global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.ResourceType? OpenapiDiscriminator + { + get { return BackingStore?.Get("openapi:discriminator"); } + set { BackingStore?.Set("openapi:discriminator", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public AttributesInCreateRequest() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.AttributesInCreateRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + var mappingValue = parseNode.GetChildNode("openapi:discriminator")?.GetStringValue(); + return mappingValue switch + { + "cupOfCoffees" => new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.AttributesInCreateCupOfCoffeeRequest(), + _ => new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.AttributesInCreateRequest(), + }; + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "openapi:discriminator", n => { OpenapiDiscriminator = n.GetEnumValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteEnumValue("openapi:discriminator", OpenapiDiscriminator); + } + } +} +#pragma warning restore CS0618 diff --git a/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/AttributesInResponse.cs b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/AttributesInResponse.cs index 9532132b93..b745c9a86b 100644 --- a/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/AttributesInResponse.cs +++ b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/AttributesInResponse.cs @@ -44,6 +44,7 @@ public AttributesInResponse() var mappingValue = parseNode.GetChildNode("openapi:discriminator")?.GetStringValue(); return mappingValue switch { + "coffeeSummaries" => new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.AttributesInCoffeeSummaryResponse(), "cupOfCoffees" => new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.AttributesInCupOfCoffeeResponse(), _ => new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.AttributesInResponse(), }; diff --git a/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/CreateCupOfCoffeeRequestDocument.cs b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/CreateCupOfCoffeeRequestDocument.cs new file mode 100644 index 0000000000..d4d15c357f --- /dev/null +++ b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/CreateCupOfCoffeeRequestDocument.cs @@ -0,0 +1,79 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class CreateCupOfCoffeeRequestDocument : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The data property + public global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.DataInCreateCupOfCoffeeRequest? Data + { + get { return BackingStore?.Get("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The meta property + public global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public CreateCupOfCoffeeRequestDocument() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.CreateCupOfCoffeeRequestDocument CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.CreateCupOfCoffeeRequestDocument(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "data", n => { Data = n.GetObjectValue(global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.DataInCreateCupOfCoffeeRequest.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteObjectValue("data", Data); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/DataInCoffeeSummaryResponse.cs b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/DataInCoffeeSummaryResponse.cs new file mode 100644 index 0000000000..e290087603 --- /dev/null +++ b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/DataInCoffeeSummaryResponse.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class DataInCoffeeSummaryResponse : global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.ResourceInResponse, IParsable + #pragma warning restore CS1591 + { + /// The attributes property + public global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.AttributesInCoffeeSummaryResponse? Attributes + { + get { return BackingStore?.Get("attributes"); } + set { BackingStore?.Set("attributes", value); } + } + + /// The id property + public string? Id + { + get { return BackingStore?.Get("id"); } + set { BackingStore?.Set("id", value); } + } + + /// The links property + public global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.ResourceLinks? Links + { + get { return BackingStore?.Get("links"); } + set { BackingStore?.Set("links", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.DataInCoffeeSummaryResponse CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.DataInCoffeeSummaryResponse(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "attributes", n => { Attributes = n.GetObjectValue(global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.AttributesInCoffeeSummaryResponse.CreateFromDiscriminatorValue); } }, + { "id", n => { Id = n.GetStringValue(); } }, + { "links", n => { Links = n.GetObjectValue(global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.ResourceLinks.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteObjectValue("attributes", Attributes); + writer.WriteStringValue("id", Id); + writer.WriteObjectValue("links", Links); + } + } +} +#pragma warning restore CS0618 diff --git a/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/DataInCreateCupOfCoffeeRequest.cs b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/DataInCreateCupOfCoffeeRequest.cs new file mode 100644 index 0000000000..61488dc293 --- /dev/null +++ b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/DataInCreateCupOfCoffeeRequest.cs @@ -0,0 +1,59 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class DataInCreateCupOfCoffeeRequest : global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.ResourceInCreateRequest, IParsable + #pragma warning restore CS1591 + { + /// The attributes property + public global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.AttributesInCreateCupOfCoffeeRequest? Attributes + { + get { return BackingStore?.Get("attributes"); } + set { BackingStore?.Set("attributes", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.DataInCreateCupOfCoffeeRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.DataInCreateCupOfCoffeeRequest(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "attributes", n => { Attributes = n.GetObjectValue(global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.AttributesInCreateCupOfCoffeeRequest.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteObjectValue("attributes", Attributes); + } + } +} +#pragma warning restore CS0618 diff --git a/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/PrimaryCoffeeSummaryResponseDocument.cs b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/PrimaryCoffeeSummaryResponseDocument.cs new file mode 100644 index 0000000000..e8aef21cf6 --- /dev/null +++ b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/PrimaryCoffeeSummaryResponseDocument.cs @@ -0,0 +1,97 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class PrimaryCoffeeSummaryResponseDocument : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The data property + public global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.DataInCoffeeSummaryResponse? Data + { + get { return BackingStore?.Get("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The included property + public List? Included + { + get { return BackingStore?.Get?>("included"); } + set { BackingStore?.Set("included", value); } + } + + /// The links property + public global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.ResourceTopLevelLinks? Links + { + get { return BackingStore?.Get("links"); } + set { BackingStore?.Set("links", value); } + } + + /// The meta property + public global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public PrimaryCoffeeSummaryResponseDocument() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.PrimaryCoffeeSummaryResponseDocument CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.PrimaryCoffeeSummaryResponseDocument(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "data", n => { Data = n.GetObjectValue(global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.DataInCoffeeSummaryResponse.CreateFromDiscriminatorValue); } }, + { "included", n => { Included = n.GetCollectionOfObjectValues(global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.ResourceInResponse.CreateFromDiscriminatorValue)?.AsList(); } }, + { "links", n => { Links = n.GetObjectValue(global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.ResourceTopLevelLinks.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteObjectValue("data", Data); + writer.WriteCollectionOfObjectValues("included", Included); + writer.WriteObjectValue("links", Links); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/PrimaryCupOfCoffeeResponseDocument.cs b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/PrimaryCupOfCoffeeResponseDocument.cs new file mode 100644 index 0000000000..7cfa219a36 --- /dev/null +++ b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/PrimaryCupOfCoffeeResponseDocument.cs @@ -0,0 +1,97 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class PrimaryCupOfCoffeeResponseDocument : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The data property + public global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.DataInCupOfCoffeeResponse? Data + { + get { return BackingStore?.Get("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The included property + public List? Included + { + get { return BackingStore?.Get?>("included"); } + set { BackingStore?.Set("included", value); } + } + + /// The links property + public global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.ResourceTopLevelLinks? Links + { + get { return BackingStore?.Get("links"); } + set { BackingStore?.Set("links", value); } + } + + /// The meta property + public global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public PrimaryCupOfCoffeeResponseDocument() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.PrimaryCupOfCoffeeResponseDocument CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.PrimaryCupOfCoffeeResponseDocument(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "data", n => { Data = n.GetObjectValue(global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.DataInCupOfCoffeeResponse.CreateFromDiscriminatorValue); } }, + { "included", n => { Included = n.GetCollectionOfObjectValues(global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.ResourceInResponse.CreateFromDiscriminatorValue)?.AsList(); } }, + { "links", n => { Links = n.GetObjectValue(global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.ResourceTopLevelLinks.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteObjectValue("data", Data); + writer.WriteCollectionOfObjectValues("included", Included); + writer.WriteObjectValue("links", Links); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/ResourceInCreateRequest.cs b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/ResourceInCreateRequest.cs new file mode 100644 index 0000000000..b5c9d28606 --- /dev/null +++ b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/ResourceInCreateRequest.cs @@ -0,0 +1,84 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class ResourceInCreateRequest : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The meta property + public global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// The type property + public global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.ResourceType? Type + { + get { return BackingStore?.Get("type"); } + set { BackingStore?.Set("type", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public ResourceInCreateRequest() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.ResourceInCreateRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + var mappingValue = parseNode.GetChildNode("type")?.GetStringValue(); + return mappingValue switch + { + "cupOfCoffees" => new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.DataInCreateCupOfCoffeeRequest(), + _ => new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.ResourceInCreateRequest(), + }; + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + { "type", n => { Type = n.GetEnumValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteObjectValue("meta", Meta); + writer.WriteEnumValue("type", Type); + } + } +} +#pragma warning restore CS0618 diff --git a/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/ResourceInResponse.cs b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/ResourceInResponse.cs index 6482b3f020..284b7d585d 100644 --- a/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/ResourceInResponse.cs +++ b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/ResourceInResponse.cs @@ -51,6 +51,7 @@ public ResourceInResponse() var mappingValue = parseNode.GetChildNode("type")?.GetStringValue(); return mappingValue switch { + "coffeeSummaries" => new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.DataInCoffeeSummaryResponse(), "cupOfCoffees" => new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.DataInCupOfCoffeeResponse(), _ => new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.ResourceInResponse(), }; diff --git a/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/ResourceTopLevelLinks.cs b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/ResourceTopLevelLinks.cs new file mode 100644 index 0000000000..5b9f14a4bf --- /dev/null +++ b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/ResourceTopLevelLinks.cs @@ -0,0 +1,79 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class ResourceTopLevelLinks : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The describedby property + public string? Describedby + { + get { return BackingStore?.Get("describedby"); } + set { BackingStore?.Set("describedby", value); } + } + + /// The self property + public string? Self + { + get { return BackingStore?.Get("self"); } + set { BackingStore?.Set("self", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public ResourceTopLevelLinks() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.ResourceTopLevelLinks CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.ResourceTopLevelLinks(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "describedby", n => { Describedby = n.GetStringValue(); } }, + { "self", n => { Self = n.GetStringValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteStringValue("describedby", Describedby); + writer.WriteStringValue("self", Self); + } + } +} +#pragma warning restore CS0618 diff --git a/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/ResourceType.cs b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/ResourceType.cs index 2db9c19590..0fd0345c87 100644 --- a/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/ResourceType.cs +++ b/test/OpenApiKiotaEndToEndTests/MixedControllers/GeneratedCode/Models/ResourceType.cs @@ -10,6 +10,10 @@ namespace OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models public enum ResourceType #pragma warning restore CS1591 { + [EnumMember(Value = "coffeeSummaries")] + #pragma warning disable CS1591 + CoffeeSummaries, + #pragma warning restore CS1591 [EnumMember(Value = "cupOfCoffees")] #pragma warning disable CS1591 CupOfCoffees, diff --git a/test/OpenApiKiotaEndToEndTests/MixedControllers/MixedControllerTests.cs b/test/OpenApiKiotaEndToEndTests/MixedControllers/MixedControllerTests.cs index 6fac5b747b..6a139d7c0e 100644 --- a/test/OpenApiKiotaEndToEndTests/MixedControllers/MixedControllerTests.cs +++ b/test/OpenApiKiotaEndToEndTests/MixedControllers/MixedControllerTests.cs @@ -1,6 +1,7 @@ using System.Net; using FluentAssertions; using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Kiota.Abstractions; @@ -14,6 +15,8 @@ using Xunit.Abstractions; using ClientEmail = OpenApiKiotaEndToEndTests.MixedControllers.GeneratedCode.Models.Email; using ServerEmail = OpenApiTests.MixedControllers.Email; +using IJsonApiOptions = JsonApiDotNetCore.Configuration.IJsonApiOptions; +using JsonApiOptions = JsonApiDotNetCore.Configuration.JsonApiOptions; namespace OpenApiKiotaEndToEndTests.MixedControllers; @@ -30,6 +33,7 @@ public MixedControllerTests(IntegrationTestContext(); testContext.UseController(); + testContext.UseController(); testContext.ConfigureServices(services => { @@ -43,6 +47,317 @@ public MixedControllerTests(IntegrationTestContext(); emailsProvider.SentEmails.Clear(); + + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); + options.AllowUnknownQueryStringParameters = true; + } + + [Fact] + public async Task Can_get_coffee_summary() + { + // Arrange + List cups = _fakers.CupOfCoffee.GenerateList(10); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.CupsOfCoffee.AddRange(cups); + await dbContext.SaveChangesAsync(); + }); + + using HttpClientRequestAdapter requestAdapter = _requestAdapterFactory.CreateAdapter(_testContext.Factory); + var apiClient = new MixedControllersClient(requestAdapter); + + // Act + PrimaryCoffeeSummaryResponseDocument? response = await apiClient.CoffeeSummaries.Summary.GetAsync(); + + // Assert + response.Should().NotBeNull(); + response.Data.Should().NotBeNull(); + response.Data.Attributes.Should().NotBeNull(); + response.Data.Attributes.TotalCount.Should().Be(10); + response.Data.Attributes.BlackCount.Should().Be(cups.Count(cup => cup is { HasMilk: false, HasSugar: false })); + response.Data.Attributes.OnlySugarCount.Should().Be(cups.Count(cup => cup is { HasMilk: false, HasSugar: true })); + response.Data.Attributes.OnlyMilkCount.Should().Be(cups.Count(cup => cup is { HasMilk: true, HasSugar: false })); + response.Data.Attributes.SugarWithMilkCount.Should().Be(cups.Count(cup => cup is { HasMilk: true, HasSugar: true })); + } + + [Fact] + public async Task Cannot_get_empty_coffee_summary() + { + // Arrange + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + }); + + using HttpClientRequestAdapter requestAdapter = _requestAdapterFactory.CreateAdapter(_testContext.Factory); + var apiClient = new MixedControllersClient(requestAdapter); + + // Act + Func action = async () => await apiClient.CoffeeSummaries.Summary.GetAsync(); + + // Assert + ErrorResponseDocument exception = (await action.Should().ThrowExactlyAsync()).Which; + exception.ResponseStatusCode.Should().Be((int)HttpStatusCode.NotFound); + exception.Message.Should().Be($"Exception of type '{typeof(ErrorResponseDocument).FullName}' was thrown."); + exception.Errors.Should().HaveCount(1); + + ErrorObject error = exception.Errors[0]; + error.Status.Should().Be("404"); + error.Title.Should().Be("No cups available to summarize."); + error.Detail.Should().BeNull(); + error.Source.Should().BeNull(); + } + + [Fact] + public async Task Can_get_only_black_cups() + { + // Arrange + List cups = _fakers.CupOfCoffee.GenerateList(2); + cups[0].HasSugar = true; + cups[1].HasMilk = false; + cups[1].HasSugar = false; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.CupsOfCoffee.AddRange(cups); + await dbContext.SaveChangesAsync(); + }); + + using HttpClientRequestAdapter requestAdapter = _requestAdapterFactory.CreateAdapter(_testContext.Factory); + var apiClient = new MixedControllersClient(requestAdapter); + + // Act + CupOfCoffeeCollectionResponseDocument? response = await apiClient.CupOfCoffees.OnlyBlack.GetAsync(); + + // Assert + response.Should().NotBeNull(); + + response.Data.Should().ContainSingle().Which.With(data => + { + data.Id.Should().Be(cups[1].StringId); + data.Attributes.Should().NotBeNull(); + data.Attributes.HasMilk.Should().BeFalse(); + data.Attributes.HasSugar.Should().BeFalse(); + }); + } + + [Fact] + public async Task Can_get_existing_black_cup() + { + // Arrange + CupOfCoffee cup = _fakers.CupOfCoffee.GenerateOne(); + cup.HasSugar = false; + cup.HasMilk = false; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.CupsOfCoffee.Add(cup); + await dbContext.SaveChangesAsync(); + }); + + using HttpClientRequestAdapter requestAdapter = _requestAdapterFactory.CreateAdapter(_testContext.Factory); + var apiClient = new MixedControllersClient(requestAdapter); + + // Act + PrimaryCupOfCoffeeResponseDocument? response = await apiClient.CupOfCoffees.OnlyBlack[cup.StringId!].GetAsync(); + + // Assert + response.Should().NotBeNull(); + response.Data.Should().NotBeNull(); + response.Data.Id.Should().Be(cup.StringId); + response.Data.Attributes.Should().NotBeNull(); + response.Data.Attributes.HasMilk.Should().BeFalse(); + response.Data.Attributes.HasSugar.Should().BeFalse(); + } + + [Fact] + public async Task Cannot_get_unknown_black_cup() + { + // Arrange + using HttpClientRequestAdapter requestAdapter = _requestAdapterFactory.CreateAdapter(_testContext.Factory); + var apiClient = new MixedControllersClient(requestAdapter); + + // Act + Func action = async () => await apiClient.CupOfCoffees.OnlyBlack[Unknown.StringId.Int64].GetAsync(); + + // Assert + ErrorResponseDocument exception = (await action.Should().ThrowExactlyAsync()).Which; + exception.ResponseStatusCode.Should().Be((int)HttpStatusCode.NotFound); + exception.Message.Should().Be($"Exception of type '{typeof(ErrorResponseDocument).FullName}' was thrown."); + exception.Errors.Should().HaveCount(1); + + ErrorObject error = exception.Errors[0]; + error.Status.Should().Be("404"); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'cupOfCoffees' with ID '{Unknown.StringId.Int64}' does not exist."); + error.Source.Should().BeNull(); + } + + [Fact] + public async Task Can_create_cups_in_batch() + { + // Arrange + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + }); + + using HttpClientRequestAdapter requestAdapter = _requestAdapterFactory.CreateAdapter(_testContext.Factory); + var apiClient = new MixedControllersClient(requestAdapter); + + CreateCupOfCoffeeRequestDocument requestBody = new() + { + Data = new DataInCreateCupOfCoffeeRequest + { + Type = ResourceType.CupOfCoffees, + Attributes = new AttributesInCreateCupOfCoffeeRequest + { + HasSugar = true, + HasMilk = true + } + } + }; + + // Act + await apiClient.CupOfCoffees.Batch.PostAsync(requestBody, configuration => configuration.QueryParameters.Size = 3); + + // Assert + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List cupsInDatabase = await dbContext.CupsOfCoffee.ToListAsync(); + + cupsInDatabase.Should().HaveCount(3); + cupsInDatabase.Should().AllSatisfy(cup => cup.HasSugar.Should().BeTrue()); + cupsInDatabase.Should().AllSatisfy(cup => cup.HasMilk.Should().BeTrue()); + }); + } + + [Fact] + public async Task Cannot_create_cups_with_negative_batch_size() + { + // Arrange + using HttpClientRequestAdapter requestAdapter = _requestAdapterFactory.CreateAdapter(_testContext.Factory); + var apiClient = new MixedControllersClient(requestAdapter); + + CreateCupOfCoffeeRequestDocument requestBody = new() + { + Data = new DataInCreateCupOfCoffeeRequest + { + Type = ResourceType.CupOfCoffees, + Attributes = new AttributesInCreateCupOfCoffeeRequest + { + HasSugar = true, + HasMilk = true + } + } + }; + + // Act + Func action = async () => await apiClient.CupOfCoffees.Batch.PostAsync(requestBody, configuration => configuration.QueryParameters.Size = -1); + + // Assert + ErrorResponseDocument exception = (await action.Should().ThrowExactlyAsync()).Which; + exception.ResponseStatusCode.Should().Be((int)HttpStatusCode.BadRequest); + exception.Message.Should().Be($"Exception of type '{typeof(ErrorResponseDocument).FullName}' was thrown."); + exception.Errors.Should().HaveCount(1); + + ErrorObject error = exception.Errors[0]; + error.Status.Should().Be("400"); + error.Title.Should().Be("Invalid batch size."); + error.Detail.Should().Be("Please specify a batch size of one or higher in the query string."); + error.Source.Should().NotBeNull(); + error.Source.Parameter.Should().Be("size"); + } + + [Fact] + public async Task Can_reset_cups_in_batch() + { + // Arrange + List cups = _fakers.CupOfCoffee.GenerateList(5); + cups[0].HasSugar = true; + cups[4].HasSugar = true; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.CupsOfCoffee.AddRange(cups); + await dbContext.SaveChangesAsync(); + }); + + using HttpClientRequestAdapter requestAdapter = _requestAdapterFactory.CreateAdapter(_testContext.Factory); + var apiClient = new MixedControllersClient(requestAdapter); + + // Act + await apiClient.CupOfCoffees.Batch.PatchAsync(); + + // Assert + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List cupsInDatabase = await dbContext.CupsOfCoffee.ToListAsync(); + + cupsInDatabase.Should().HaveCount(5); + cupsInDatabase.Should().AllSatisfy(cup => cup.HasSugar.Should().BeFalse()); + cupsInDatabase.Should().AllSatisfy(cup => cup.HasMilk.Should().BeFalse()); + }); + } + + [Fact] + public async Task Can_delete_all_cups() + { + // Arrange + List cups = _fakers.CupOfCoffee.GenerateList(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.CupsOfCoffee.AddRange(cups); + await dbContext.SaveChangesAsync(); + }); + + using HttpClientRequestAdapter requestAdapter = _requestAdapterFactory.CreateAdapter(_testContext.Factory); + var apiClient = new MixedControllersClient(requestAdapter); + + // Act + await apiClient.CupOfCoffees.Batch.DeleteAsync(); + + // Assert + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List cupsInDatabase = await dbContext.CupsOfCoffee.ToListAsync(); + + cupsInDatabase.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Cannot_delete_all_cups_when_empty() + { + // Arrange + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + }); + + using HttpClientRequestAdapter requestAdapter = _requestAdapterFactory.CreateAdapter(_testContext.Factory); + var apiClient = new MixedControllersClient(requestAdapter); + + // Act + Func action = async () => await apiClient.CupOfCoffees.Batch.DeleteAsync(); + + // Assert + ErrorResponseDocument exception = (await action.Should().ThrowExactlyAsync()).Which; + exception.ResponseStatusCode.Should().Be((int)HttpStatusCode.NotFound); + exception.Message.Should().Be($"Exception of type '{typeof(ErrorResponseDocument).FullName}' was thrown."); + exception.Errors.Should().HaveCount(1); + + ErrorObject error = exception.Errors[0]; + error.Status.Should().Be("404"); + error.Title.Should().BeNull(); + error.Detail.Should().BeNull(); + error.Source.Should().BeNull(); } [Fact] @@ -209,7 +524,6 @@ public async Task Cannot_send_email_with_invalid_addresses() // Assert HttpValidationProblemDetails exception = (await action.Should().ThrowExactlyAsync()).Which; - exception.Status.Should().Be((int)HttpStatusCode.BadRequest); exception.Type.Should().Be("https://tools.ietf.org/html/rfc9110#section-15.5.1"); exception.Title.Should().Be("One or more validation errors occurred."); @@ -272,7 +586,6 @@ public async Task Cannot_get_sent_emails_in_future() // Assert HttpValidationProblemDetails exception = (await action.Should().ThrowExactlyAsync()).Which; - exception.Status.Should().Be((int)HttpStatusCode.BadRequest); exception.Type.Should().Be("https://tools.ietf.org/html/rfc9110#section-15.5.1"); exception.Title.Should().Be("One or more validation errors occurred."); @@ -302,7 +615,6 @@ public async Task Can_try_get_sent_emails_in_future() // Assert ApiException exception = (await action.Should().ThrowExactlyAsync()).Which; - exception.ResponseStatusCode.Should().Be((int)HttpStatusCode.BadRequest); exception.ResponseHeaders.Should().BeEmpty(); } diff --git a/test/OpenApiNSwagEndToEndTests/MixedControllers/GeneratedCode/MixedControllersClient.cs b/test/OpenApiNSwagEndToEndTests/MixedControllers/GeneratedCode/MixedControllersClient.cs new file mode 100644 index 0000000000..b738ec32bf --- /dev/null +++ b/test/OpenApiNSwagEndToEndTests/MixedControllers/GeneratedCode/MixedControllersClient.cs @@ -0,0 +1,19 @@ +using System.Text; + +// Justification: The CA1852 analyzer doesn't take auto-generated code into account, presumably for improved performance. +#pragma warning disable CA1852 // Type can be sealed because it has no subtypes in its containing assembly and is not externally visible + +namespace OpenApiNSwagEndToEndTests.MixedControllers.GeneratedCode; + +internal partial class MixedControllersClient +{ + // ReSharper disable once UnusedParameterInPartialMethod + partial void PrepareRequest(HttpClient client, HttpRequestMessage request, StringBuilder urlBuilder) + { + if (request.Method == HttpMethod.Patch && urlBuilder.ToString().EndsWith("/cupOfCoffees/batch", StringComparison.Ordinal)) + { + // Workaround: NSwag assumes a PATCH request must always send a request body, despite our OpenAPI document not specifying one. + request.Content = null; + } + } +} diff --git a/test/OpenApiNSwagEndToEndTests/MixedControllers/MixedControllerTests.cs b/test/OpenApiNSwagEndToEndTests/MixedControllers/MixedControllerTests.cs index ee98280ae6..a58fdcd267 100644 --- a/test/OpenApiNSwagEndToEndTests/MixedControllers/MixedControllerTests.cs +++ b/test/OpenApiNSwagEndToEndTests/MixedControllers/MixedControllerTests.cs @@ -1,7 +1,9 @@ using System.Net; using FluentAssertions; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.OpenApi.Client.NSwag; using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using OpenApiNSwagEndToEndTests.MixedControllers.GeneratedCode; @@ -27,6 +29,7 @@ public MixedControllerTests(IntegrationTestContext(); testContext.UseController(); + testContext.UseController(); testContext.ConfigureServices(services => { @@ -40,6 +43,309 @@ public MixedControllerTests(IntegrationTestContext(); emailsProvider.SentEmails.Clear(); + + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); + options.AllowUnknownQueryStringParameters = true; + } + + [Fact] + public async Task Can_get_coffee_summary() + { + // Arrange + List cups = _fakers.CupOfCoffee.GenerateList(10); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.CupsOfCoffee.AddRange(cups); + await dbContext.SaveChangesAsync(); + }); + + using HttpClient httpClient = _testContext.Factory.CreateDefaultClient(_logHttpMessageHandler); + var apiClient = new MixedControllersClient(httpClient); + + // Act + PrimaryCoffeeSummaryResponseDocument response = await apiClient.GetCoffeeSummaryAsync(); + + // Assert + response.Data.Attributes.Should().NotBeNull(); + response.Data.Attributes.TotalCount.Should().Be(10); + response.Data.Attributes.BlackCount.Should().Be(cups.Count(cup => cup is { HasMilk: false, HasSugar: false })); + response.Data.Attributes.OnlySugarCount.Should().Be(cups.Count(cup => cup is { HasMilk: false, HasSugar: true })); + response.Data.Attributes.OnlyMilkCount.Should().Be(cups.Count(cup => cup is { HasMilk: true, HasSugar: false })); + response.Data.Attributes.SugarWithMilkCount.Should().Be(cups.Count(cup => cup is { HasMilk: true, HasSugar: true })); + } + + [Fact] + public async Task Cannot_get_empty_coffee_summary() + { + // Arrange + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + }); + + using HttpClient httpClient = _testContext.Factory.CreateDefaultClient(_logHttpMessageHandler); + var apiClient = new MixedControllersClient(httpClient); + + // Act + Func action = async () => await apiClient.GetCoffeeSummaryAsync(); + + // Assert + ApiException exception = (await action.Should().ThrowExactlyAsync>()).Which; + exception.StatusCode.Should().Be((int)HttpStatusCode.NotFound); + exception.Message.Should().Be("HTTP 404: Not Found"); + exception.Result.Errors.Should().HaveCount(1); + + ErrorObject error = exception.Result.Errors.ElementAt(0); + error.Status.Should().Be("404"); + error.Title.Should().Be("No cups available to summarize."); + error.Detail.Should().BeNull(); + error.Source.Should().BeNull(); + } + + [Fact] + public async Task Can_get_only_black_cups() + { + // Arrange + List cups = _fakers.CupOfCoffee.GenerateList(2); + cups[0].HasSugar = true; + cups[1].HasMilk = false; + cups[1].HasSugar = false; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.CupsOfCoffee.AddRange(cups); + await dbContext.SaveChangesAsync(); + }); + + using HttpClient httpClient = _testContext.Factory.CreateDefaultClient(_logHttpMessageHandler); + var apiClient = new MixedControllersClient(httpClient); + + // Act + CupOfCoffeeCollectionResponseDocument response = await apiClient.GetOnlyBlackAsync(); + + // Assert + response.Data.Should().ContainSingle().Which.With(data => + { + data.Id.Should().Be(cups[1].StringId); + data.Attributes.Should().NotBeNull(); + data.Attributes.HasMilk.Should().BeFalse(); + data.Attributes.HasSugar.Should().BeFalse(); + }); + } + + [Fact] + public async Task Can_get_existing_black_cup() + { + // Arrange + CupOfCoffee cup = _fakers.CupOfCoffee.GenerateOne(); + cup.HasSugar = false; + cup.HasMilk = false; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.CupsOfCoffee.Add(cup); + await dbContext.SaveChangesAsync(); + }); + + using HttpClient httpClient = _testContext.Factory.CreateDefaultClient(_logHttpMessageHandler); + var apiClient = new MixedControllersClient(httpClient); + + // Act + PrimaryCupOfCoffeeResponseDocument response = await apiClient.GetOnlyIfBlackAsync(cup.StringId!); + + // Assert + response.Data.Id.Should().Be(cup.StringId); + response.Data.Attributes.Should().NotBeNull(); + response.Data.Attributes.HasMilk.Should().BeFalse(); + response.Data.Attributes.HasSugar.Should().BeFalse(); + } + + [Fact] + public async Task Cannot_get_unknown_black_cup() + { + // Arrange + using HttpClient httpClient = _testContext.Factory.CreateDefaultClient(_logHttpMessageHandler); + var apiClient = new MixedControllersClient(httpClient); + + // Act + Func action = async () => await apiClient.GetOnlyIfBlackAsync(Unknown.StringId.Int64); + + // Assert + ApiException exception = (await action.Should().ThrowExactlyAsync>()).Which; + exception.StatusCode.Should().Be((int)HttpStatusCode.NotFound); + exception.Message.Should().Be("HTTP 404: Not Found"); + exception.Result.Errors.Should().HaveCount(1); + + ErrorObject error = exception.Result.Errors.ElementAt(0); + error.Status.Should().Be("404"); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'cupOfCoffees' with ID '{Unknown.StringId.Int64}' does not exist."); + error.Source.Should().BeNull(); + } + + [Fact] + public async Task Can_create_cups_in_batch() + { + // Arrange + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + }); + + using HttpClient httpClient = _testContext.Factory.CreateDefaultClient(_logHttpMessageHandler); + var apiClient = new MixedControllersClient(httpClient); + + CreateCupOfCoffeeRequestDocument requestBody = new() + { + Data = new DataInCreateCupOfCoffeeRequest + { + Attributes = new AttributesInCreateCupOfCoffeeRequest + { + HasSugar = true, + HasMilk = true + } + } + }; + + // Act + await apiClient.BatchCreateCupsOfCoffeeAsync(3, requestBody); + + // Assert + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List cupsInDatabase = await dbContext.CupsOfCoffee.ToListAsync(); + + cupsInDatabase.Should().HaveCount(3); + cupsInDatabase.Should().AllSatisfy(cup => cup.HasSugar.Should().BeTrue()); + cupsInDatabase.Should().AllSatisfy(cup => cup.HasMilk.Should().BeTrue()); + }); + } + + [Fact] + public async Task Cannot_create_cups_with_negative_batch_size() + { + // Arrange + using HttpClient httpClient = _testContext.Factory.CreateDefaultClient(_logHttpMessageHandler); + var apiClient = new MixedControllersClient(httpClient); + + CreateCupOfCoffeeRequestDocument requestBody = new() + { + Data = new DataInCreateCupOfCoffeeRequest + { + Attributes = new AttributesInCreateCupOfCoffeeRequest + { + HasSugar = true, + HasMilk = true + } + } + }; + + // Act + Func action = async () => await apiClient.BatchCreateCupsOfCoffeeAsync(-1, requestBody); + + // Assert + ApiException exception = (await action.Should().ThrowExactlyAsync>()).Which; + exception.StatusCode.Should().Be((int)HttpStatusCode.BadRequest); + exception.Message.Should().Be("HTTP 400: Bad Request"); + exception.Result.Errors.Should().HaveCount(1); + + ErrorObject error = exception.Result.Errors.ElementAt(0); + error.Status.Should().Be("400"); + error.Title.Should().Be("Invalid batch size."); + error.Detail.Should().Be("Please specify a batch size of one or higher in the query string."); + error.Source.Should().NotBeNull(); + error.Source.Parameter.Should().Be("size"); + } + + [Fact] + public async Task Can_reset_cups_in_batch() + { + // Arrange + List cups = _fakers.CupOfCoffee.GenerateList(5); + cups[0].HasSugar = true; + cups[4].HasSugar = true; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.CupsOfCoffee.AddRange(cups); + await dbContext.SaveChangesAsync(); + }); + + using HttpClient httpClient = _testContext.Factory.CreateDefaultClient(_logHttpMessageHandler); + var apiClient = new MixedControllersClient(httpClient); + + // Act + await apiClient.BatchResetToBlackAsync(); + + // Assert + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List cupsInDatabase = await dbContext.CupsOfCoffee.ToListAsync(); + + cupsInDatabase.Should().HaveCount(5); + cupsInDatabase.Should().AllSatisfy(cup => cup.HasSugar.Should().BeFalse()); + cupsInDatabase.Should().AllSatisfy(cup => cup.HasMilk.Should().BeFalse()); + }); + } + + [Fact] + public async Task Can_delete_all_cups() + { + // Arrange + List cups = _fakers.CupOfCoffee.GenerateList(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.CupsOfCoffee.AddRange(cups); + await dbContext.SaveChangesAsync(); + }); + + using HttpClient httpClient = _testContext.Factory.CreateDefaultClient(_logHttpMessageHandler); + var apiClient = new MixedControllersClient(httpClient); + + // Act + await apiClient.DeleteAllAsync(); + + // Assert + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List cupsInDatabase = await dbContext.CupsOfCoffee.ToListAsync(); + + cupsInDatabase.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Cannot_delete_all_cups_when_empty() + { + // Arrange + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + }); + + using HttpClient httpClient = _testContext.Factory.CreateDefaultClient(_logHttpMessageHandler); + var apiClient = new MixedControllersClient(httpClient); + + // Act + Func action = async () => await apiClient.DeleteAllAsync(); + + // Assert + ApiException exception = (await action.Should().ThrowExactlyAsync>()).Which; + exception.StatusCode.Should().Be((int)HttpStatusCode.NotFound); + exception.Message.Should().Be("HTTP 404: Not Found"); + exception.Result.Errors.Should().HaveCount(1); + + ErrorObject error = exception.Result.Errors.ElementAt(0); + error.Status.Should().Be("404"); + error.Title.Should().BeNull(); + error.Detail.Should().BeNull(); + error.Source.Should().BeNull(); } [Fact] @@ -77,7 +383,6 @@ public async Task Cannot_upload_empty_file() // Assert ApiException exception = (await action.Should().ThrowExactlyAsync()).Which; - exception.StatusCode.Should().Be((int)HttpStatusCode.BadRequest); exception.Message.Should().Be("HTTP 400: Bad Request"); exception.Response.Should().Be("Empty files cannot be uploaded."); @@ -114,7 +419,6 @@ public async Task Does_not_find_missing_file() // Assert ApiException exception = (await action.Should().ThrowExactlyAsync()).Which; - exception.StatusCode.Should().Be((int)HttpStatusCode.NotFound); exception.Message.Should().Be("HTTP 404: Not Found"); exception.Response.Should().BeNull(); @@ -158,7 +462,6 @@ public async Task Cannot_download_missing_file() // Assert ApiException exception = (await action.Should().ThrowExactlyAsync()).Which; - exception.StatusCode.Should().Be((int)HttpStatusCode.NotFound); exception.Message.Should().Be("HTTP 404: Not Found"); exception.Response.Should().Be("The file 'demo-missing-file.txt' does not exist."); @@ -214,7 +517,6 @@ public async Task Cannot_send_email_with_invalid_addresses() // Assert ApiException exception = (await action.Should().ThrowExactlyAsync>()).Which; - exception.StatusCode.Should().Be((int)HttpStatusCode.BadRequest); exception.Message.Should().Be("HTTP 400: Bad Request"); exception.Result.Status.Should().Be((int)HttpStatusCode.BadRequest); @@ -274,7 +576,6 @@ public async Task Cannot_get_sent_emails_in_future() // Assert ApiException exception = (await action.Should().ThrowExactlyAsync>()).Which; - exception.StatusCode.Should().Be((int)HttpStatusCode.BadRequest); exception.Message.Should().Be("HTTP 400: Bad Request"); exception.Result.Status.Should().Be((int)HttpStatusCode.BadRequest); @@ -303,7 +604,6 @@ public async Task Can_try_get_sent_emails_in_future() // Assert ApiException exception = (await action.Should().ThrowExactlyAsync()).Which; - exception.StatusCode.Should().Be((int)HttpStatusCode.BadRequest); exception.Message.Should().Be("HTTP 400: Bad Request"); exception.Response.Should().BeNull(); diff --git a/test/OpenApiTests/MixedControllers/CoffeeSummaryController.cs b/test/OpenApiTests/MixedControllers/CoffeeSummaryController.cs index 84f552800e..76da665267 100644 --- a/test/OpenApiTests/MixedControllers/CoffeeSummaryController.cs +++ b/test/OpenApiTests/MixedControllers/CoffeeSummaryController.cs @@ -1,7 +1,6 @@ using System.Net; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -25,8 +24,9 @@ public CoffeeSummaryController(CoffeeDbContext dbContext, IJsonApiOptions option [HttpGet("summary", Name = "get-coffee-summary")] [HttpHead("summary", Name = "head-coffee-summary")] [EndpointDescription("Summarizes all cups of coffee, indicating their ingredients.")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task GetSummaryAsync(CancellationToken cancellationToken) + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetSummaryAsync(CancellationToken cancellationToken) { var summary = new CoffeeSummary { @@ -65,21 +65,15 @@ public async Task GetSummaryAsync(CancellationToken cancellationT summary.TotalCount++; } - return summary; - } - - [HttpDelete("only-milk", Name = "delete-only-milk")] - [EndpointDescription("Deletes all cups of coffee with milk, but no sugar.")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task DeleteOnlyMilkAsync(CancellationToken cancellationToken) - { - int numDeleted = await _dbContext.CupsOfCoffee.Where(cupOfCoffee => cupOfCoffee.HasMilk == true && cupOfCoffee.HasSugar != true) - .ExecuteDeleteAsync(cancellationToken); - - if (numDeleted == 0) + if (summary.TotalCount == 0) { - throw new JsonApiException(new ErrorObject(HttpStatusCode.NotFound)); + return Error(new ErrorObject(HttpStatusCode.NotFound) + { + StatusCode = HttpStatusCode.NotFound, + Title = "No cups available to summarize." + }); } + + return Ok(summary); } } diff --git a/test/OpenApiTests/MixedControllers/CupOfCoffeesController.cs b/test/OpenApiTests/MixedControllers/CupOfCoffeesController.cs new file mode 100644 index 0000000000..eeda10d02d --- /dev/null +++ b/test/OpenApiTests/MixedControllers/CupOfCoffeesController.cs @@ -0,0 +1,129 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Net; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +#pragma warning disable format + +namespace OpenApiTests.MixedControllers; + +partial class CupOfCoffeesController +{ + private readonly CoffeeDbContext _dbContext; + + [ActivatorUtilitiesConstructor] + public CupOfCoffeesController(CoffeeDbContext dbContext, IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IGetAllService getAll, IDeleteService delete) + : base(options, resourceGraph, loggerFactory, getAll, delete: delete) + { + ArgumentNullException.ThrowIfNull(dbContext); + + _dbContext = dbContext; + } + + [HttpGet("onlyBlack", Name = "get-only-black")] + [HttpHead("onlyBlack", Name = "head-only-black")] + [EndpointDescription("Gets all cups of coffee without sugar and milk.")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task GetOnlyBlackAsync(CancellationToken cancellationToken) + { + List cups = await _dbContext.CupsOfCoffee.Where(cup => cup.HasSugar == false && cup.HasMilk == false).ToListAsync(cancellationToken); + return Ok(cups); + } + + [HttpGet("onlyBlack/{id}", Name = "get-only-if-black")] + [HttpHead("onlyBlack/{id}", Name = "head-only-if-black")] + [EndpointDescription("Gets a cup of coffee by ID, if the cup is without sugar and milk. Returns 404 otherwise.")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetIfOnlyBlackAsync([Required] long id, CancellationToken cancellationToken) + { + CupOfCoffee? cup = await _dbContext.CupsOfCoffee.Where(cup => cup.Id == id && cup.HasSugar == false && cup.HasMilk == false) + .FirstOrDefaultAsync(cancellationToken); + + if (cup == null) + { + throw new ResourceNotFoundException(id.ToString(), "cupOfCoffees"); + } + + return Ok(cup); + } + + [HttpPost("batch", Name = "batchCreateCupsOfCoffee")] + [EndpointDescription("Creates cups of coffee in batch.")] + [Consumes(typeof(CupOfCoffee), "application/vnd.api+json")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task CreateBatchAsync([FromQuery] [Required] [Description("The batch size.")] int size, + [FromBody] [Required] CupOfCoffee template, CancellationToken cancellationToken) + { + if (size < 1) + { + return Error(new ErrorObject(HttpStatusCode.BadRequest) + { + StatusCode = HttpStatusCode.BadRequest, + Title = "Invalid batch size.", + Detail = "Please specify a batch size of one or higher in the query string.", + Source = new ErrorSource + { + Parameter = "size" + } + }); + } + + for (int index = 0; index < size; index++) + { + var cup = new CupOfCoffee + { + HasSugar = template.HasSugar, + HasMilk = template.HasMilk + }; + + _dbContext.Add(cup); + } + + await _dbContext.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + [HttpPatch("batch", Name = "batchResetToBlack")] + [EndpointDescription("Resets all cups of coffee to black.")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task ResetAllToBlackAsync(CancellationToken cancellationToken) + { + // @formatter:keep_existing_linebreaks true + + await _dbContext.CupsOfCoffee.ExecuteUpdateAsync(setters => setters + .SetProperty(cup => cup.HasSugar, false) + .SetProperty(cup => cup.HasMilk, false), + cancellationToken); + + // @formatter:keep_existing_linebreaks restore + + return NoContent(); + } + + [HttpDelete("batch", Name = "deleteAll")] + [EndpointDescription("Deletes all cups of coffee. Returns 404 when none found.")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteAsync(CancellationToken cancellationToken) + { + int numDeleted = await _dbContext.CupsOfCoffee.ExecuteDeleteAsync(cancellationToken); + + if (numDeleted == 0) + { + throw new JsonApiException(new ErrorObject(HttpStatusCode.NotFound)); + } + + Response.StatusCode = (int)HttpStatusCode.NoContent; + } +} diff --git a/test/OpenApiTests/MixedControllers/GeneratedSwagger/swagger.g.json b/test/OpenApiTests/MixedControllers/GeneratedSwagger/swagger.g.json index 7b8fc14830..a7a9229cd5 100644 --- a/test/OpenApiTests/MixedControllers/GeneratedSwagger/swagger.g.json +++ b/test/OpenApiTests/MixedControllers/GeneratedSwagger/swagger.g.json @@ -10,6 +10,240 @@ } ], "paths": { + "/coffeeSummaries/summary": { + "get": { + "tags": [ + "coffeeSummaries" + ], + "description": "Summarizes all cups of coffee, indicating their ingredients.", + "operationId": "get-coffee-summary", + "responses": { + "200": { + "description": "OK", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/primaryCoffeeSummaryResponseDocument" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "head": { + "tags": [ + "coffeeSummaries" + ], + "description": "Summarizes all cups of coffee, indicating their ingredients.", + "operationId": "head-coffee-summary", + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found" + } + } + } + }, + "/cupOfCoffees/onlyBlack": { + "get": { + "tags": [ + "cupOfCoffees" + ], + "description": "Gets all cups of coffee without sugar and milk.", + "operationId": "get-only-black", + "responses": { + "200": { + "description": "OK", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/cupOfCoffeeCollectionResponseDocument" + } + } + } + } + } + }, + "head": { + "tags": [ + "cupOfCoffees" + ], + "description": "Gets all cups of coffee without sugar and milk.", + "operationId": "head-only-black", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/cupOfCoffees/onlyBlack/{id}": { + "get": { + "tags": [ + "cupOfCoffees" + ], + "description": "Gets a cup of coffee by ID, if the cup is without sugar and milk. Returns 404 otherwise.", + "operationId": "get-only-if-black", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/primaryCupOfCoffeeResponseDocument" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "head": { + "tags": [ + "cupOfCoffees" + ], + "description": "Gets a cup of coffee by ID, if the cup is without sugar and milk. Returns 404 otherwise.", + "operationId": "head-only-if-black", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found" + } + } + } + }, + "/cupOfCoffees/batch": { + "post": { + "tags": [ + "cupOfCoffees" + ], + "description": "Creates cups of coffee in batch.", + "operationId": "batchCreateCupsOfCoffee", + "parameters": [ + { + "name": "size", + "in": "query", + "description": "The batch size.", + "required": true, + "schema": { + "type": "integer", + "description": "The batch size.", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/createCupOfCoffeeRequestDocument" + } + ] + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "patch": { + "tags": [ + "cupOfCoffees" + ], + "description": "Resets all cups of coffee to black.", + "operationId": "batchResetToBlack", + "responses": { + "204": { + "description": "No Content" + } + } + }, + "delete": { + "tags": [ + "cupOfCoffees" + ], + "description": "Deletes all cups of coffee. Returns 404 when none found.", + "operationId": "deleteAll", + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + } + }, "/cupOfCoffees": { "get": { "tags": [ @@ -457,6 +691,87 @@ }, "components": { "schemas": { + "attributesInCoffeeSummaryResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/attributesInResponse" + }, + { + "type": "object", + "properties": { + "totalCount": { + "type": "integer", + "format": "int32" + }, + "blackCount": { + "type": "integer", + "format": "int32" + }, + "onlySugarCount": { + "type": "integer", + "format": "int32" + }, + "onlyMilkCount": { + "type": "integer", + "format": "int32" + }, + "sugarWithMilkCount": { + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "attributesInCreateCupOfCoffeeRequest": { + "allOf": [ + { + "$ref": "#/components/schemas/attributesInCreateRequest" + }, + { + "required": [ + "hasMilk", + "hasSugar" + ], + "type": "object", + "properties": { + "hasSugar": { + "type": "boolean" + }, + "hasMilk": { + "type": "boolean" + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "attributesInCreateRequest": { + "required": [ + "openapi:discriminator" + ], + "type": "object", + "properties": { + "openapi:discriminator": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceType" + } + ] + } + }, + "additionalProperties": false, + "discriminator": { + "propertyName": "openapi:discriminator", + "mapping": { + "cupOfCoffees": "#/components/schemas/attributesInCreateCupOfCoffeeRequest" + } + }, + "x-abstract": true + }, "attributesInCupOfCoffeeResponse": { "allOf": [ { @@ -495,11 +810,35 @@ "discriminator": { "propertyName": "openapi:discriminator", "mapping": { + "coffeeSummaries": "#/components/schemas/attributesInCoffeeSummaryResponse", "cupOfCoffees": "#/components/schemas/attributesInCupOfCoffeeResponse" } }, "x-abstract": true }, + "createCupOfCoffeeRequestDocument": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/dataInCreateCupOfCoffeeRequest" + } + ] + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, "cupOfCoffeeCollectionResponseDocument": { "required": [ "data", @@ -536,6 +875,64 @@ }, "additionalProperties": false }, + "dataInCoffeeSummaryResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceInResponse" + }, + { + "required": [ + "id" + ], + "type": "object", + "properties": { + "id": { + "minLength": 1, + "type": "string", + "format": "int64" + }, + "attributes": { + "allOf": [ + { + "$ref": "#/components/schemas/attributesInCoffeeSummaryResponse" + } + ] + }, + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceLinks" + } + ] + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "dataInCreateCupOfCoffeeRequest": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/resourceInCreateRequest" + }, + { + "type": "object", + "properties": { + "attributes": { + "allOf": [ + { + "$ref": "#/components/schemas/attributesInCreateCupOfCoffeeRequest" + } + ] + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, "dataInCupOfCoffeeResponse": { "allOf": [ { @@ -773,6 +1170,80 @@ "nullable": true } }, + "primaryCoffeeSummaryResponseDocument": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceTopLevelLinks" + } + ] + }, + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/dataInCoffeeSummaryResponse" + } + ] + }, + "included": { + "type": "array", + "items": { + "$ref": "#/components/schemas/resourceInResponse" + } + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "primaryCupOfCoffeeResponseDocument": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceTopLevelLinks" + } + ] + }, + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/dataInCupOfCoffeeResponse" + } + ] + }, + "included": { + "type": "array", + "items": { + "$ref": "#/components/schemas/resourceInResponse" + } + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, "resourceCollectionTopLevelLinks": { "type": "object", "properties": { @@ -797,6 +1268,36 @@ }, "additionalProperties": false }, + "resourceInCreateRequest": { + "required": [ + "type" + ], + "type": "object", + "properties": { + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceType" + } + ] + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false, + "discriminator": { + "propertyName": "type", + "mapping": { + "cupOfCoffees": "#/components/schemas/dataInCreateCupOfCoffeeRequest" + } + }, + "x-abstract": true + }, "resourceInResponse": { "required": [ "type" @@ -822,6 +1323,7 @@ "discriminator": { "propertyName": "type", "mapping": { + "coffeeSummaries": "#/components/schemas/dataInCoffeeSummaryResponse", "cupOfCoffees": "#/components/schemas/dataInCupOfCoffeeResponse" } }, @@ -836,8 +1338,21 @@ }, "additionalProperties": false }, + "resourceTopLevelLinks": { + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "describedby": { + "type": "string" + } + }, + "additionalProperties": false + }, "resourceType": { "enum": [ + "coffeeSummaries", "cupOfCoffees" ], "type": "string" diff --git a/test/OpenApiTests/MixedControllers/LoggingTests.cs b/test/OpenApiTests/MixedControllers/LoggingTests.cs deleted file mode 100644 index 6fd4452536..0000000000 --- a/test/OpenApiTests/MixedControllers/LoggingTests.cs +++ /dev/null @@ -1,51 +0,0 @@ -using FluentAssertions; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using TestBuildingBlocks; -using Xunit; -using Xunit.Abstractions; - -namespace OpenApiTests.MixedControllers; - -public sealed class LoggingTests : IClassFixture> -{ - private readonly OpenApiTestContext _testContext; - - public LoggingTests(OpenApiTestContext testContext, ITestOutputHelper testOutputHelper) - { - _testContext = testContext; - - testContext.UseController(); - - testContext.SetTestOutputHelper(testOutputHelper); - - testContext.ConfigureServices(services => services.AddLogging(builder => - { - var loggerProvider = new CapturingLoggerProvider(LogLevel.Warning); - builder.AddProvider(loggerProvider); - builder.SetMinimumLevel(LogLevel.Warning); - - builder.Services.AddSingleton(loggerProvider); - })); - } - - [Fact] - public async Task Logs_warning_for_unsupported_custom_actions_in_JsonApi_controllers() - { - // Arrange - var loggerProvider = _testContext.Factory.Services.GetRequiredService(); - - // Act - await _testContext.GetSwaggerDocumentAsync(); - - // Assert - IReadOnlyList logLines = loggerProvider.GetLines(); - - logLines.Should().BeEquivalentTo(new[] - { - $"[WARNING] Hiding unsupported custom JSON:API action method [GET] {typeof(CoffeeSummaryController)}.GetSummaryAsync (OpenApiTests) in OpenAPI.", - $"[WARNING] Hiding unsupported custom JSON:API action method [HEAD] {typeof(CoffeeSummaryController)}.GetSummaryAsync (OpenApiTests) in OpenAPI.", - $"[WARNING] Hiding unsupported custom JSON:API action method [DELETE] {typeof(CoffeeSummaryController)}.DeleteOnlyMilkAsync (OpenApiTests) in OpenAPI." - }, options => options.WithStrictOrdering()); - } -} diff --git a/test/OpenApiTests/MixedControllers/MixedControllerTests.cs b/test/OpenApiTests/MixedControllers/MixedControllerTests.cs index bd064968ff..8c47effd74 100644 --- a/test/OpenApiTests/MixedControllers/MixedControllerTests.cs +++ b/test/OpenApiTests/MixedControllers/MixedControllerTests.cs @@ -43,6 +43,307 @@ public async Task Default_JsonApi_endpoints_are_exposed() document.Should().ContainPath("paths./cupOfCoffees/{id}.delete"); } + [Fact] + public async Task Get_coffee_summaries_endpoint_is_exposed() + { + // Act + JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + + // Assert + document.Should().ContainPath("paths./coffeeSummaries/summary.get").Should().BeJson(""" + { + "tags": [ + "coffeeSummaries" + ], + "description": "Summarizes all cups of coffee, indicating their ingredients.", + "operationId": "get-coffee-summary", + "responses": { + "200": { + "description": "OK", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/primaryCoffeeSummaryResponseDocument" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + } + """); + + document.Should().ContainPath("paths./coffeeSummaries/summary.head").Should().BeJson(""" + { + "tags": [ + "coffeeSummaries" + ], + "description": "Summarizes all cups of coffee, indicating their ingredients.", + "operationId": "head-coffee-summary", + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found" + } + } + } + """); + } + + [Fact] + public async Task Get_black_cups_endpoint_is_exposed() + { + // Act + JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + + // Assert + document.Should().ContainPath("paths./cupOfCoffees/onlyBlack.get").Should().BeJson(""" + { + "tags": [ + "cupOfCoffees" + ], + "description": "Gets all cups of coffee without sugar and milk.", + "operationId": "get-only-black", + "responses": { + "200": { + "description": "OK", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/cupOfCoffeeCollectionResponseDocument" + } + } + } + } + } + } + """); + + document.Should().ContainPath("paths./cupOfCoffees/onlyBlack.head").Should().BeJson(""" + { + "tags": [ + "cupOfCoffees" + ], + "description": "Gets all cups of coffee without sugar and milk.", + "operationId": "head-only-black", + "responses": { + "200": { + "description": "OK" + } + } + } + """); + } + + [Fact] + public async Task Get_black_cup_endpoint_is_exposed() + { + // Act + JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + + // Assert + document.Should().ContainPath("paths./cupOfCoffees/onlyBlack/{id}.get").Should().BeJson(""" + { + "tags": [ + "cupOfCoffees" + ], + "description": "Gets a cup of coffee by ID, if the cup is without sugar and milk. Returns 404 otherwise.", + "operationId": "get-only-if-black", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/primaryCupOfCoffeeResponseDocument" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + } + """); + + document.Should().ContainPath("paths./cupOfCoffees/onlyBlack/{id}.head").Should().BeJson(""" + { + "tags": [ + "cupOfCoffees" + ], + "description": "Gets a cup of coffee by ID, if the cup is without sugar and milk. Returns 404 otherwise.", + "operationId": "head-only-if-black", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found" + } + } + } + """); + } + + [Fact] + public async Task Batch_create_cups_endpoint_is_exposed() + { + // Act + JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + + // Assert + document.Should().ContainPath("paths./cupOfCoffees/batch.post").Should().BeJson(""" + { + "tags": [ + "cupOfCoffees" + ], + "description": "Creates cups of coffee in batch.", + "operationId": "batchCreateCupsOfCoffee", + "parameters": [ + { + "name": "size", + "in": "query", + "description": "The batch size.", + "required": true, + "schema": { + "type": "integer", + "description": "The batch size.", + "format": "int32" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/createCupOfCoffeeRequestDocument" + } + ] + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + } + """); + } + + [Fact] + public async Task Batch_update_cups_endpoint_is_exposed() + { + // Act + JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + + // Assert + document.Should().ContainPath("paths./cupOfCoffees/batch.patch").Should().BeJson(""" + { + "tags": [ + "cupOfCoffees" + ], + "description": "Resets all cups of coffee to black.", + "operationId": "batchResetToBlack", + "responses": { + "204": { + "description": "No Content" + } + } + } + """); + } + + [Fact] + public async Task Batch_delete_cups_endpoint_is_exposed() + { + // Act + JsonElement document = await _testContext.GetSwaggerDocumentAsync(); + + // Assert + document.Should().ContainPath("paths./cupOfCoffees/batch.delete").Should().BeJson(""" + { + "tags": [ + "cupOfCoffees" + ], + "description": "Deletes all cups of coffee. Returns 404 when none found.", + "operationId": "deleteAll", + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + } + """); + } + [Fact] public async Task Upload_file_endpoint_is_exposed() {