From f7551579d573804daaf6639d1989f174b851815b Mon Sep 17 00:00:00 2001 From: Sam Xu Date: Wed, 13 Aug 2025 12:06:51 -0700 Subject: [PATCH] Enable $batch request with minimal API --- sample/ODataMiniApi/ODataMetadataApi.http | 40 ++++++- sample/ODataMiniApi/Program.cs | 3 + .../Batch/DefaultODataBatchHandler.cs | 34 +++++- .../Batch/ODataBatchHandler.cs | 16 +++ .../Batch/ODataMiniBatchMiddleware.cs | 103 ++++++++++++++++++ .../Batch/UnbufferedODataBatchHandler.cs | 37 +++++-- .../Extensions/HttpContextExtensions.cs | 6 +- .../Microsoft.AspNetCore.OData.xml | 61 +++++++++++ .../ODataApplicationBuilderExtensions.cs | 53 +++++++++ .../ODataMiniOptions.cs | 18 +++ .../PublicAPI.Unshipped.txt | 7 ++ .../MinimalAPITodoEndpointsTest.cs | 92 ++++++++++++++++ .../Microsoft.AspNetCore.OData.PublicApi.bsl | 21 ++++ 13 files changed, 477 insertions(+), 14 deletions(-) create mode 100644 src/Microsoft.AspNetCore.OData/Batch/ODataMiniBatchMiddleware.cs diff --git a/sample/ODataMiniApi/ODataMetadataApi.http b/sample/ODataMiniApi/ODataMetadataApi.http index 471b1a5f0..39536d475 100644 --- a/sample/ODataMiniApi/ODataMetadataApi.http +++ b/sample/ODataMiniApi/ODataMetadataApi.http @@ -67,4 +67,42 @@ GET {{ODataMiniApi_HostAddress}}/v1/$metadata?$format=application/json GET {{ODataMiniApi_HostAddress}}/v1/$metadata Accept: application/json -# The above two requests get the CSDL-JSON file \ No newline at end of file +# The above two requests get the CSDL-JSON file + +### + +POST {{ODataMiniApi_HostAddress}}/v1/$batch +Content-Type: application/json +Accept: application/json + +{ + "requests": [ + { + "id": "1", + "atomicityGroup": "f7de7314-2f3d-4422-b840-ada6d6de0f18", + "method": "POST", + "url": "http://localhost:5177/v1/customers/rating", + "headers": { + "OData-Version": "4.0", + "Content-Type": "application/json", + "Accept": "application/json" + }, + "body": { + "p": 8 + } + }, + { + "id": "2", + "atomicityGroup": "f7de7314-2f3d-4422-b840-ada6d6de0f18", + "method": "GET", + "url": "http://localhost:5177/v1/customers", + "headers": { + "OData-Version": "4.0", + "Content-Type": "application/json;odata.metadata=minimal", + "Accept": "application/json;odata.metadata=minimal" + } + } + ] +} + +### \ No newline at end of file diff --git a/sample/ODataMiniApi/Program.cs b/sample/ODataMiniApi/Program.cs index e7acc2fb6..022653e7a 100644 --- a/sample/ODataMiniApi/Program.cs +++ b/sample/ODataMiniApi/Program.cs @@ -38,6 +38,9 @@ services.AddSingleton(); }; +// Be noted: should call UseODataMiniBatching() as early as possible to support OData batching. +app.UseODataMiniBatching("v1/$batch", model); + app.MapGet("test", () => "hello world").WithODataResult(); // Group diff --git a/src/Microsoft.AspNetCore.OData/Batch/DefaultODataBatchHandler.cs b/src/Microsoft.AspNetCore.OData/Batch/DefaultODataBatchHandler.cs index 9efc441d8..578985620 100644 --- a/src/Microsoft.AspNetCore.OData/Batch/DefaultODataBatchHandler.cs +++ b/src/Microsoft.AspNetCore.OData/Batch/DefaultODataBatchHandler.cs @@ -41,10 +41,20 @@ public override async Task ProcessBatchAsync(HttpContext context, RequestDelegat return; } + SetMinimalApi(context); + IList subRequests = await ParseBatchRequestsAsync(context).ConfigureAwait(false); - ODataOptions options = context.RequestServices.GetRequiredService>().Value; - bool enableContinueOnErrorHeader = (options != null) ? options.EnableContinueOnErrorHeader : false; + bool enableContinueOnErrorHeader = false; + if (MiniMetadata != null) + { + enableContinueOnErrorHeader = MiniMetadata.Options.EnableContinueOnErrorHeader; + } + else + { + ODataOptions options = context.RequestServices.GetRequiredService>().Value; + enableContinueOnErrorHeader = (options != null) ? options.EnableContinueOnErrorHeader : false; + } SetContinueOnError(context.Request.Headers, enableContinueOnErrorHeader); @@ -100,7 +110,23 @@ public virtual async Task> ParseBatchRequestsAsync( } HttpRequest request = context.Request; - IServiceProvider requestContainer = request.CreateRouteServices(PrefixName); + + IServiceProvider requestContainer = null; + if (MiniMetadata != null) + { + requestContainer = MiniMetadata.ServiceProvider; + + // We should dispose the scope after the request is processed? + // In the case of batch request, we can leave the GC to clean up the scope? + IServiceScope scope = requestContainer.GetRequiredService().CreateScope(); + request.ODataFeature().Services = scope.ServiceProvider; + request.ODataFeature().RequestScope = scope; + } + else + { + requestContainer = request.CreateRouteServices(PrefixName); + } + requestContainer.GetRequiredService().BaseUri = GetBaseUri(request); using (ODataMessageReader reader = request.GetODataMessageReader(requestContainer)) @@ -135,7 +161,7 @@ public virtual async Task> ParseBatchRequestsAsync( } } - return requests; + return requests; } } } diff --git a/src/Microsoft.AspNetCore.OData/Batch/ODataBatchHandler.cs b/src/Microsoft.AspNetCore.OData/Batch/ODataBatchHandler.cs index 007b24bc5..c2fd1759c 100644 --- a/src/Microsoft.AspNetCore.OData/Batch/ODataBatchHandler.cs +++ b/src/Microsoft.AspNetCore.OData/Batch/ODataBatchHandler.cs @@ -98,6 +98,11 @@ public virtual Uri GetBaseUri(HttpRequest request) /// internal bool ContinueOnError { get; private set; } + /// + /// Gets or sets the mini metadata for the OData batch handler. + /// + internal ODataMiniMetadata MiniMetadata { get; private set; } + /// /// Set ContinueOnError based on the request and headers. /// @@ -118,4 +123,15 @@ internal void SetContinueOnError(IHeaderDictionary header, bool enableContinueOn ContinueOnError = false; } } + + internal void SetMinimalApi(HttpContext context) + { + ODataMiniMetadata metadata = null; + if (context.Items.TryGetValue(ODataMiniBatchMiddleware.MinimalApiMetadataKey, out object value)) + { + metadata = value as ODataMiniMetadata; + } + + MiniMetadata = metadata; + } } diff --git a/src/Microsoft.AspNetCore.OData/Batch/ODataMiniBatchMiddleware.cs b/src/Microsoft.AspNetCore.OData/Batch/ODataMiniBatchMiddleware.cs new file mode 100644 index 000000000..3a6201454 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Batch/ODataMiniBatchMiddleware.cs @@ -0,0 +1,103 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OData.Extensions; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Template; + +namespace Microsoft.AspNetCore.OData.Batch; + +/// +/// Defines the middleware for handling OData $batch requests in the minimal API scenarios. +/// This middleware essentially acts like branching middleware and redirects OData $batch +/// requests to the appropriate ODataBatchHandler. +/// +public class ODataMiniBatchMiddleware +{ + internal const string MinimalApiMetadataKey = "MS_MinimalApiMetadataKey_386F76A4-5E7C-4B4D-8A20-261A23C3DD9A"; + + private readonly RequestDelegate _next; + private readonly TemplateMatcher _routeMatcher; + private readonly ODataBatchHandler _handler; + private readonly ODataMiniMetadata _metadata; + + /// + /// Instantiates a new instance of . + /// + /// The route pattern for the OData $batch endpoint. + /// The handler for processing batch requests. + /// the metadata for the OData $batch endpoint. + /// The next middleware. + public ODataMiniBatchMiddleware(string routePattern, ODataBatchHandler handler, ODataMiniMetadata metadata, RequestDelegate next) + { + if (routePattern == null) + { + throw Error.ArgumentNull(nameof(routePattern)); + } + + // ensure _routePattern starts with / + string newRouteTemplate = routePattern.StartsWith("/", StringComparison.Ordinal) ? routePattern.Substring(1) : routePattern; + RouteTemplate parsedTemplate = TemplateParser.Parse(newRouteTemplate); + _routeMatcher = new TemplateMatcher(parsedTemplate, new RouteValueDictionary()); + _handler = handler ?? throw Error.ArgumentNull(nameof(handler)); + _metadata = metadata ?? throw Error.ArgumentNull(nameof(metadata)); + + _next = next; + } + + /// + /// Invoke the OData $Batch middleware. + /// + /// The http context. + /// A task that can be awaited. + public async Task Invoke(HttpContext context) + { + if (context == null) + { + throw Error.ArgumentNull(nameof(context)); + } + + // The batch middleware should not handle the options requests for cors to properly function. + bool isPostRequest = HttpMethods.IsPost(context.Request.Method); + if (!isPostRequest) + { + await _next(context); + } + else + { + string path = context.Request.Path; + RouteValueDictionary routeData = new RouteValueDictionary(); + if (_routeMatcher.TryMatch(path, routeData)) + { + if (routeData.Count > 0) + { + Merge(context.ODataFeature().BatchRouteData, routeData); + } + + // Add the ODataMiniMetadata to the context items for later retrieval. + context.Items.Add(MinimalApiMetadataKey, _metadata); + + await _handler.ProcessBatchAsync(context, _next); + } + else + { + await _next(context); + } + } + } + + private static void Merge(RouteValueDictionary batchRouteData, RouteValueDictionary routeData) + { + foreach (var item in routeData) + { + batchRouteData.Add(item.Key, item.Value); + } + } +} diff --git a/src/Microsoft.AspNetCore.OData/Batch/UnbufferedODataBatchHandler.cs b/src/Microsoft.AspNetCore.OData/Batch/UnbufferedODataBatchHandler.cs index d8df27db5..6fce46e8b 100644 --- a/src/Microsoft.AspNetCore.OData/Batch/UnbufferedODataBatchHandler.cs +++ b/src/Microsoft.AspNetCore.OData/Batch/UnbufferedODataBatchHandler.cs @@ -7,7 +7,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; @@ -41,21 +40,45 @@ public override async Task ProcessBatchAsync(HttpContext context, RequestDelegat return; } + SetMinimalApi(context); + // This container is for the overall batch request. HttpRequest request = context.Request; - IServiceProvider requestContainer = request.CreateRouteServices(PrefixName); + + IServiceProvider requestContainer = null; + if (MiniMetadata != null) + { + requestContainer = MiniMetadata.ServiceProvider; + + // We should dispose the scope after the request is processed? + // In the case of batch request, we can leave the GC to clean up the scope? + IServiceScope scope = requestContainer.GetRequiredService().CreateScope(); + request.ODataFeature().Services = scope.ServiceProvider; + request.ODataFeature().RequestScope = scope; + } + else + { + requestContainer = request.CreateRouteServices(PrefixName); + } + requestContainer.GetRequiredService().BaseUri = GetBaseUri(request); List responses = new List(); - + using (ODataMessageReader reader = request.GetODataMessageReader(requestContainer)) { ODataBatchReader batchReader = await reader.CreateODataBatchReaderAsync().ConfigureAwait(false); Guid batchId = Guid.NewGuid(); - ODataOptions options = context.RequestServices.GetRequiredService>().Value; - bool enableContinueOnErrorHeader = (options != null) - ? options.EnableContinueOnErrorHeader - : false; + bool enableContinueOnErrorHeader = false; + if (MiniMetadata != null) + { + enableContinueOnErrorHeader = MiniMetadata.Options.EnableContinueOnErrorHeader; + } + else + { + ODataOptions options = context.RequestServices.GetRequiredService>().Value; + enableContinueOnErrorHeader = (options != null) ? options.EnableContinueOnErrorHeader : false; + } SetContinueOnError(request.Headers, enableContinueOnErrorHeader); diff --git a/src/Microsoft.AspNetCore.OData/Extensions/HttpContextExtensions.cs b/src/Microsoft.AspNetCore.OData/Extensions/HttpContextExtensions.cs index 4e151c32d..91e7219e4 100644 --- a/src/Microsoft.AspNetCore.OData/Extensions/HttpContextExtensions.cs +++ b/src/Microsoft.AspNetCore.OData/Extensions/HttpContextExtensions.cs @@ -203,10 +203,12 @@ internal static IServiceProvider GetOrCreateServiceProvider(this HttpContext htt // 2. Retrieve it from metadata? var endpoint = httpContext.GetEndpoint(); - var odataMiniMetadata = endpoint.Metadata.GetMetadata(); + var odataMiniMetadata = endpoint?.Metadata.GetMetadata(); if (odataMiniMetadata is not null) { - odataFeature.Services = odataMiniMetadata.ServiceProvider; + IServiceScope scope = odataMiniMetadata.ServiceProvider.GetRequiredService().CreateScope(); + odataFeature.Services = scope.ServiceProvider; + odataFeature.RequestScope = scope; return odataFeature.Services; } diff --git a/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml b/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml index 75b46fbed..e9ec089be 100644 --- a/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml +++ b/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml @@ -489,6 +489,11 @@ Gets or sets if the continue-on-error header is enable or not. + + + Gets or sets the mini metadata for the OData batch handler. + + Set ContinueOnError based on the request and headers. @@ -743,6 +748,29 @@ copy it to the batch response stream. + + + Defines the middleware for handling OData $batch requests in the minimal API scenarios. + This middleware essentially acts like branching middleware and redirects OData $batch + requests to the appropriate ODataBatchHandler. + + + + + Instantiates a new instance of . + + The route pattern for the OData $batch endpoint. + The handler for processing batch requests. + the metadata for the OData $batch endpoint. + The next middleware. + + + + Invoke the OData $Batch middleware. + + The http context. + A task that can be awaited. + Represents an Operation request. @@ -6557,6 +6585,26 @@ The to use. The . + + + Use OData batching minimal API middleware. + + The to use. + The route pattern, + The edm model. + The . + + + + Use OData batching minimal API middleware. + + The to use. + The route pattern, + The edm model. + The batch handler. + The services configuration. + The . + Use OData query request middleware. An OData query request is a Http Post request ending with /$query. @@ -6797,6 +6845,12 @@ Please call 'SetCaseInsensitive()' to config. + + + Gets whether or not continue on error. + Please call 'SetContinueOnErrorHeader()' to config. + + Gets TimeZoneInfo for the serialization and deserialization. @@ -6817,6 +6871,13 @@ Case insensitive or not. The current instance to enable further configuration. + + + Config the value indicating if batch requests should continue on error. + + the value indicating if batch requests should continue on error. + The current instance to enable further configuration. + Config the time zone information. diff --git a/src/Microsoft.AspNetCore.OData/ODataApplicationBuilderExtensions.cs b/src/Microsoft.AspNetCore.OData/ODataApplicationBuilderExtensions.cs index dbef4aed9..174b3927f 100644 --- a/src/Microsoft.AspNetCore.OData/ODataApplicationBuilderExtensions.cs +++ b/src/Microsoft.AspNetCore.OData/ODataApplicationBuilderExtensions.cs @@ -9,6 +9,10 @@ using Microsoft.AspNetCore.OData.Batch; using Microsoft.AspNetCore.OData.Query; using Microsoft.AspNetCore.OData.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.OData.Edm; +using System; namespace Microsoft.AspNetCore.OData; @@ -34,6 +38,55 @@ public static IApplicationBuilder UseODataBatching(this IApplicationBuilder app) return app.UseMiddleware(); } + /// + /// Use OData batching minimal API middleware. + /// + /// The to use. + /// The route pattern, + /// The edm model. + /// The . + public static IApplicationBuilder UseODataMiniBatching(this IApplicationBuilder app, string routePattern, IEdmModel model) + => app.UseODataMiniBatching(routePattern, model, new DefaultODataBatchHandler()); + + /// + /// Use OData batching minimal API middleware. + /// + /// The to use. + /// The route pattern, + /// The edm model. + /// The batch handler. + /// The services configuration. + /// The . + public static IApplicationBuilder UseODataMiniBatching(this IApplicationBuilder app, string routePattern, IEdmModel model, + ODataBatchHandler handler, Action configAction = null) + { + if (app == null) + { + throw Error.ArgumentNull(nameof(app)); + } + + ODataMiniMetadata metadata = new ODataMiniMetadata(); + + // retrieve the global minimal API OData configuration + ODataMiniOptions options = app.ApplicationServices.GetService>()?.Value; + if (options is not null) + { + metadata.Options.UpdateFrom(options); + } + + metadata.Model = model; + if (configAction != null) + { + metadata.Services = configAction; + } + + app.UseMiddleware(routePattern, handler, metadata); + + // This is required to enable the OData batch request. + // Otherwise, the sub requests will not be routed correctly. + return app.UseRouting(); + } + /// /// Use OData query request middleware. An OData query request is a Http Post request ending with /$query. /// The Request body contains the query options. diff --git a/src/Microsoft.AspNetCore.OData/ODataMiniOptions.cs b/src/Microsoft.AspNetCore.OData/ODataMiniOptions.cs index 466d0678a..ea3ac6899 100644 --- a/src/Microsoft.AspNetCore.OData/ODataMiniOptions.cs +++ b/src/Microsoft.AspNetCore.OData/ODataMiniOptions.cs @@ -20,6 +20,7 @@ public class ODataMiniOptions private DefaultQueryConfigurations _queryConfigurations = new DefaultQueryConfigurations(); private bool _enableNoDollarQueryOptions = true; private bool _enableCaseInsensitive = true; + private bool _enableContinueOnErrorHeader = false; private TimeZoneInfo _timeZone = TimeZoneInfo.Local; private ODataVersion _version = ODataVersionConstraint.DefaultODataVersion; @@ -46,6 +47,12 @@ public class ODataMiniOptions /// public bool EnableCaseInsensitive { get => _enableCaseInsensitive; } + /// + /// Gets whether or not continue on error. + /// Please call 'SetContinueOnErrorHeader()' to config. + /// + public bool EnableContinueOnErrorHeader { get => _enableContinueOnErrorHeader; } + /// /// Gets TimeZoneInfo for the serialization and deserialization. /// Please call 'SetTimeZoneInfo()' to config. @@ -74,6 +81,17 @@ public ODataMiniOptions SetCaseInsensitive(bool enableCaseInsensitive) return this; } + /// + /// Config the value indicating if batch requests should continue on error. + /// + /// the value indicating if batch requests should continue on error. + /// The current instance to enable further configuration. + public ODataMiniOptions SetContinueOnErrorHeader(bool enableContinueOnErrorHeader) + { + _enableCaseInsensitive = enableContinueOnErrorHeader; + return this; + } + /// /// Config the time zone information. /// diff --git a/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt b/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt index b87ed82ca..2a007b928 100644 --- a/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt +++ b/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt @@ -1,3 +1,6 @@ +Microsoft.AspNetCore.OData.Batch.ODataMiniBatchMiddleware +Microsoft.AspNetCore.OData.Batch.ODataMiniBatchMiddleware.Invoke(Microsoft.AspNetCore.Http.HttpContext context) -> System.Threading.Tasks.Task +Microsoft.AspNetCore.OData.Batch.ODataMiniBatchMiddleware.ODataMiniBatchMiddleware(string routePattern, Microsoft.AspNetCore.OData.Batch.ODataBatchHandler handler, Microsoft.AspNetCore.OData.ODataMiniMetadata metadata, Microsoft.AspNetCore.Http.RequestDelegate next) -> void Microsoft.AspNetCore.OData.Edm.IODataModelConfiguration Microsoft.AspNetCore.OData.Edm.IODataModelConfiguration.Apply(Microsoft.AspNetCore.Http.HttpContext context, Microsoft.OData.ModelBuilder.ODataModelBuilder builder, System.Type clrType) -> Microsoft.OData.ModelBuilder.ODataModelBuilder Microsoft.AspNetCore.OData.Extensions.ODataQueryFilterInvocationContext @@ -29,6 +32,7 @@ Microsoft.AspNetCore.OData.ODataMiniOptions Microsoft.AspNetCore.OData.ODataMiniOptions.Count() -> Microsoft.AspNetCore.OData.ODataMiniOptions Microsoft.AspNetCore.OData.ODataMiniOptions.EnableAll(int? maxTopValue = null) -> Microsoft.AspNetCore.OData.ODataMiniOptions Microsoft.AspNetCore.OData.ODataMiniOptions.EnableCaseInsensitive.get -> bool +Microsoft.AspNetCore.OData.ODataMiniOptions.EnableContinueOnErrorHeader.get -> bool Microsoft.AspNetCore.OData.ODataMiniOptions.EnableNoDollarQueryOptions.get -> bool Microsoft.AspNetCore.OData.ODataMiniOptions.Expand() -> Microsoft.AspNetCore.OData.ODataMiniOptions Microsoft.AspNetCore.OData.ODataMiniOptions.Filter() -> Microsoft.AspNetCore.OData.ODataMiniOptions @@ -37,6 +41,7 @@ Microsoft.AspNetCore.OData.ODataMiniOptions.OrderBy() -> Microsoft.AspNetCore.OD Microsoft.AspNetCore.OData.ODataMiniOptions.QueryConfigurations.get -> Microsoft.AspNetCore.OData.Query.DefaultQueryConfigurations Microsoft.AspNetCore.OData.ODataMiniOptions.Select() -> Microsoft.AspNetCore.OData.ODataMiniOptions Microsoft.AspNetCore.OData.ODataMiniOptions.SetCaseInsensitive(bool enableCaseInsensitive) -> Microsoft.AspNetCore.OData.ODataMiniOptions +Microsoft.AspNetCore.OData.ODataMiniOptions.SetContinueOnErrorHeader(bool enableContinueOnErrorHeader) -> Microsoft.AspNetCore.OData.ODataMiniOptions Microsoft.AspNetCore.OData.ODataMiniOptions.SetMaxTop(int? maxTopValue) -> Microsoft.AspNetCore.OData.ODataMiniOptions Microsoft.AspNetCore.OData.ODataMiniOptions.SetNoDollarQueryOptions(bool enableNoDollarQueryOptions) -> Microsoft.AspNetCore.OData.ODataMiniOptions Microsoft.AspNetCore.OData.ODataMiniOptions.SetTimeZoneInfo(System.TimeZoneInfo tzi) -> Microsoft.AspNetCore.OData.ODataMiniOptions @@ -62,6 +67,8 @@ static Microsoft.AspNetCore.OData.Deltas.Delta.BindAsync(Microsoft.AspNetCore static Microsoft.AspNetCore.OData.Deltas.DeltaSet.BindAsync(Microsoft.AspNetCore.Http.HttpContext context, System.Reflection.ParameterInfo parameter) -> System.Threading.Tasks.ValueTask> static Microsoft.AspNetCore.OData.Formatter.ODataActionParameters.BindAsync(Microsoft.AspNetCore.Http.HttpContext context, System.Reflection.ParameterInfo parameter) -> System.Threading.Tasks.ValueTask static Microsoft.AspNetCore.OData.Formatter.ODataUntypedActionParameters.BindAsync(Microsoft.AspNetCore.Http.HttpContext context, System.Reflection.ParameterInfo parameter) -> System.Threading.Tasks.ValueTask +static Microsoft.AspNetCore.OData.ODataApplicationBuilderExtensions.UseODataMiniBatching(this Microsoft.AspNetCore.Builder.IApplicationBuilder app, string routePattern, Microsoft.OData.Edm.IEdmModel model) -> Microsoft.AspNetCore.Builder.IApplicationBuilder +static Microsoft.AspNetCore.OData.ODataApplicationBuilderExtensions.UseODataMiniBatching(this Microsoft.AspNetCore.Builder.IApplicationBuilder app, string routePattern, Microsoft.OData.Edm.IEdmModel model, Microsoft.AspNetCore.OData.Batch.ODataBatchHandler handler, System.Action configAction = null) -> Microsoft.AspNetCore.Builder.IApplicationBuilder static Microsoft.AspNetCore.OData.ODataEndpointConventionBuilderExtensions.AddODataQueryEndpointFilter(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder builder, Microsoft.AspNetCore.OData.Query.IODataQueryEndpointFilter queryFilter) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder static Microsoft.AspNetCore.OData.ODataEndpointConventionBuilderExtensions.AddODataQueryEndpointFilter(this Microsoft.AspNetCore.Builder.RouteHandlerBuilder builder, System.Action validationSetup = null, System.Action querySetup = null) -> Microsoft.AspNetCore.Builder.RouteHandlerBuilder static Microsoft.AspNetCore.OData.ODataEndpointConventionBuilderExtensions.AddODataQueryEndpointFilter(this Microsoft.AspNetCore.Routing.RouteGroupBuilder builder, Microsoft.AspNetCore.OData.Query.IODataQueryEndpointFilter queryFilter) -> Microsoft.AspNetCore.Routing.RouteGroupBuilder diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/MinimalApis/MinimalAPITodoEndpointsTest.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/MinimalApis/MinimalAPITodoEndpointsTest.cs index 6156e91d8..4c11745ed 100644 --- a/test/Microsoft.AspNetCore.OData.E2E.Tests/MinimalApis/MinimalAPITodoEndpointsTest.cs +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/MinimalApis/MinimalAPITodoEndpointsTest.cs @@ -7,15 +7,18 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.OData.Deltas; +using Microsoft.AspNetCore.OData.E2E.Tests.Commons; using Microsoft.AspNetCore.OData.Query; using Microsoft.AspNetCore.OData.TestCommon; using Microsoft.Extensions.DependencyInjection; +using Microsoft.OData; using Microsoft.OData.Edm; using Microsoft.OData.UriParser; using System; using System.Linq; using System.Net; using System.Net.Http; +using System.Net.Http.Headers; using System.Text; using System.Threading.Tasks; using Xunit; @@ -40,6 +43,8 @@ protected static void ConfigureServices(IServiceCollection services) protected static void ConfigureAPIs(WebApplication app) { + app.UseODataMiniBatching("odata/$batch", MinimalEdmModel.GetEdmModel()); + app.MapGet("v0/todos", (IMiniTodoTaskRepository db) => db.GetTodos()); app.MapGet("v1/todos", (IMiniTodoTaskRepository db) => db.GetTodos()) @@ -188,4 +193,91 @@ public async Task PatchChangesToTodos_WithODataResult_WithModel_WithPath_Returns Assert.Equal(HttpStatusCode.OK, result.StatusCode); Assert.Equal("{\"@context\":\"http://localhost/v2/$metadata#Edm.String\",\"value\":\"Patch : '2' to todos\"}", content); } + + [Fact] + public async Task PostBatchRequest_WithMinimalBatchMiddleware_JsonBatch() + { + // Arrange & Act + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, "odata/$batch"); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json")); + HttpContent content = new StringContent(@" + { + ""requests"": [{ + ""id"": ""1"", + ""atomicityGroup"": ""f7de7314-2f3d-4422-b840-ada6d6de0f18"", + ""method"": ""GET"", + ""url"": ""http://localhost/v2/todos/4?$select=title,Tasks($select=Description;$orderby=id desc)"", + ""headers"": { + ""OData-Version"": ""4.0"", + ""Content-Type"": ""application/json;odata.metadata=minimal"", + ""Accept"": ""application/json;odata.metadata=minimal"" + } + }, { + ""id"": ""2"", + ""atomicityGroup"": ""f7de7314-2f3d-4422-b840-ada6d6de0f18"", + ""method"": ""GET"", + ""url"": ""http://localhost/v1/todos/3"", + ""headers"": { + ""OData-Version"": ""4.0"", + ""Content-Type"": ""application/json;odata.metadata=minimal"", + ""Accept"": ""application/json;odata.metadata=minimal"" + } + } + ] + }"); + content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/json"); + request.Content = content; + HttpResponseMessage response = await _client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Please keep the below code commented out for reference. + // The response should be a JSON batch response with two sub-responses similar to the following: + // Since the atomicity group is random, Let's read the batch response payload using OData batch reader. + + //string payload = await response.Content.ReadAsStringAsync(); + //Assert.Equal("{\"responses\":" + + // "[" + + // "{" + + // "\"id\":\"1\"," + + // "\"atomicityGroup\":\"8dcc2af5-2c74-4ec9-8638-ea41c4f8d685\"," + + // "\"status\":200," + + // "\"headers\":{\"content-type\":\"application/json\",\"odata-version\":\"4.0\"}," + + // "\"body\" :{\"@odata.context\":\"http://localhost/v2/$metadata#Todos(Title,Tasks/Description)/$entity\",\"Title\":\"Clean House\",\"Tasks\":[{\"Description\":\"Clean bathroom\"},{\"Description\":\"Clean carpet\"}]}" + + // "}," + + // "{" + + // "\"id\":\"2\"," + + // "\"atomicityGroup\":\"8dcc2af5-2c74-4ec9-8638-ea41c4f8d685\"," + + // "\"status\":200," + + // "\"headers\":{\"content-type\":\"application/json\",\"odata-version\":\"4.0\"}, " + + // "\"body\" :{\"@odata.context\":\"http://localhost/$metadata#Todos/$entity\",\"Id\":3,\"Owner\":\"John\",\"Title\":\"Shopping\",\"IsDone\":true,\"Tasks\":[{\"Id\":31,\"Description\":\"Buy bread\",\"Created\":\"2022-02-11\",\"IsComplete\":false,\"Priority\":3},{\"Id\":32,\"Description\":\"Buy washing machine\",\"Created\":\"2023-12-14\",\"IsComplete\":true,\"Priority\":2}]}" + + // "}" + + // "]" + + //"}", payload); + + var stream = await response.Content.ReadAsStreamAsync(); + IODataResponseMessage odataResponseMessage = new ODataMessageWrapper(stream, response.Content.Headers); + int subResponseCount = 0; + using (var messageReader = new ODataMessageReader(odataResponseMessage, new ODataMessageReaderSettings(), MinimalEdmModel.GetEdmModel())) + { + var batchReader = messageReader.CreateODataBatchReader(); + while (batchReader.Read()) + { + switch (batchReader.State) + { + case ODataBatchReaderState.Operation: + var operationMessage = batchReader.CreateOperationResponseMessage(); + subResponseCount++; + + // Verfiy the status code of each sub-response. + Assert.Equal(200, operationMessage.StatusCode); + break; + } + } + } + + // Verify that we have two sub-responses. + Assert.Equal(2, subResponseCount); + } } diff --git a/test/Microsoft.AspNetCore.OData.Tests/PublicApi/Microsoft.AspNetCore.OData.PublicApi.bsl b/test/Microsoft.AspNetCore.OData.Tests/PublicApi/Microsoft.AspNetCore.OData.PublicApi.bsl index 65a1e9bc2..4f1c61fd3 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/PublicApi/Microsoft.AspNetCore.OData.PublicApi.bsl +++ b/test/Microsoft.AspNetCore.OData.Tests/PublicApi/Microsoft.AspNetCore.OData.PublicApi.bsl @@ -7,6 +7,16 @@ public sealed class Microsoft.AspNetCore.OData.ODataApplicationBuilderExtensions ] public static Microsoft.AspNetCore.Builder.IApplicationBuilder UseODataBatching (Microsoft.AspNetCore.Builder.IApplicationBuilder app) + [ + ExtensionAttribute(), + ] + public static Microsoft.AspNetCore.Builder.IApplicationBuilder UseODataMiniBatching (Microsoft.AspNetCore.Builder.IApplicationBuilder app, string routePattern, Microsoft.OData.Edm.IEdmModel model) + + [ + ExtensionAttribute(), + ] + public static Microsoft.AspNetCore.Builder.IApplicationBuilder UseODataMiniBatching (Microsoft.AspNetCore.Builder.IApplicationBuilder app, string routePattern, Microsoft.OData.Edm.IEdmModel model, Microsoft.AspNetCore.OData.Batch.ODataBatchHandler handler, params System.Action`1[[Microsoft.Extensions.DependencyInjection.IServiceCollection]] configAction) + [ ExtensionAttribute(), ] @@ -196,6 +206,7 @@ public class Microsoft.AspNetCore.OData.ODataMiniOptions { public ODataMiniOptions () bool EnableCaseInsensitive { public get; } + bool EnableContinueOnErrorHeader { public get; } bool EnableNoDollarQueryOptions { public get; } Microsoft.AspNetCore.OData.Query.DefaultQueryConfigurations QueryConfigurations { public get; } System.TimeZoneInfo TimeZone { public get; } @@ -208,6 +219,7 @@ public class Microsoft.AspNetCore.OData.ODataMiniOptions { public Microsoft.AspNetCore.OData.ODataMiniOptions OrderBy () public Microsoft.AspNetCore.OData.ODataMiniOptions Select () public Microsoft.AspNetCore.OData.ODataMiniOptions SetCaseInsensitive (bool enableCaseInsensitive) + public Microsoft.AspNetCore.OData.ODataMiniOptions SetContinueOnErrorHeader (bool enableContinueOnErrorHeader) public Microsoft.AspNetCore.OData.ODataMiniOptions SetMaxTop (System.Nullable`1[[System.Int32]] maxTopValue) public Microsoft.AspNetCore.OData.ODataMiniOptions SetNoDollarQueryOptions (bool enableNoDollarQueryOptions) public Microsoft.AspNetCore.OData.ODataMiniOptions SetTimeZoneInfo (System.TimeZoneInfo tzi) @@ -537,6 +549,15 @@ public class Microsoft.AspNetCore.OData.Batch.ODataBatchMiddleware { public System.Threading.Tasks.Task Invoke (Microsoft.AspNetCore.Http.HttpContext context) } +public class Microsoft.AspNetCore.OData.Batch.ODataMiniBatchMiddleware { + public ODataMiniBatchMiddleware (string routePattern, Microsoft.AspNetCore.OData.Batch.ODataBatchHandler handler, Microsoft.AspNetCore.OData.ODataMiniMetadata metadata, Microsoft.AspNetCore.Http.RequestDelegate next) + + [ + AsyncStateMachineAttribute(), + ] + public System.Threading.Tasks.Task Invoke (Microsoft.AspNetCore.Http.HttpContext context) +} + public class Microsoft.AspNetCore.OData.Batch.OperationRequestItem : Microsoft.AspNetCore.OData.Batch.ODataBatchRequestItem { public OperationRequestItem (Microsoft.AspNetCore.Http.HttpContext context)