diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 39d66005a0f..ebf0efcb3af 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -20,6 +20,7 @@ + diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Extensions/EndpointRouteBuilderExtensions.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Extensions/EndpointRouteBuilderExtensions.cs index 140b3e88c28..6e12393ffd1 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Extensions/EndpointRouteBuilderExtensions.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Extensions/EndpointRouteBuilderExtensions.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using ChilliCream.Nitro.App; +using ChilliCream.Nitro.App.Embed; using HotChocolate.AspNetCore; using HotChocolate.AspNetCore.Extensions; using Microsoft.AspNetCore.Http; @@ -86,7 +87,7 @@ public static GraphQLEndpointConventionBuilder MapGraphQL( var pattern = Parse(path + "/{**slug}"); var requestPipeline = endpointRouteBuilder.CreateApplicationBuilder(); var services = endpointRouteBuilder.ServiceProvider; - requestPipeline.MapGraphQL(path, schemaNameOrDefault); + MapGraphQLPipeline(requestPipeline, path, schemaNameOrDefault, mountedOnEndpoint: true); var serverOptions = services.GetServerOptions(schemaNameOrDefault); return new GraphQLEndpointConventionBuilder( @@ -123,6 +124,15 @@ public static IApplicationBuilder MapGraphQL( ArgumentNullException.ThrowIfNull(applicationBuilder); ArgumentException.ThrowIfNullOrEmpty(schemaName); + return MapGraphQLPipeline(applicationBuilder, path, schemaName, mountedOnEndpoint: false); + } + + private static IApplicationBuilder MapGraphQLPipeline( + IApplicationBuilder applicationBuilder, + PathString path, + string schemaName, + bool mountedOnEndpoint) + { path = path.ToString().TrimEnd('/'); var services = applicationBuilder.ApplicationServices; @@ -144,7 +154,20 @@ public static IApplicationBuilder MapGraphQL( executor, serverOptions, path, MiddlewareRoutingType.Integrated)) .UseWhen( context => context.MayReachNitroApp(path), - branch => branch.UseNitroApp(path, serverOptions.Tool)) + branch => + { + if (mountedOnEndpoint) + { + branch.UseNitroApp(serverOptions.Tool, EmbeddedNitroApp.AssetSource); + } + else + { + branch.Use(NitroAppMiddleware.Create( + serverOptions.Tool, + path, + embeddedAssetSource: EmbeddedNitroApp.AssetSource)); + } + }) .Use(MiddlewareFactory.CreateHttpUnsupportedRequestMiddleware( executor, serverOptions, @@ -529,7 +552,7 @@ public static NitroAppEndpointConventionBuilder MapNitroApp( var nitroOptions = new NitroAppOptions { GraphQLEndpoint = relativeRequestPath }; requestPipeline - .UseNitroApp(toolPath, nitroOptions) + .UseNitroApp(nitroOptions, EmbeddedNitroApp.AssetSource) .Use(_ => context => { context.Response.StatusCode = 404; diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Extensions/HttpContextExtensions.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Extensions/HttpContextExtensions.cs index 08ee1cad817..db345c6b9e1 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Extensions/HttpContextExtensions.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/Extensions/HttpContextExtensions.cs @@ -1,6 +1,8 @@ +using ChilliCream.Nitro.App; using HotChocolate.AspNetCore.Formatters; using HotChocolate.AspNetCore.Utilities; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; namespace HotChocolate.AspNetCore; @@ -29,7 +31,7 @@ public static bool MayReachNitroApp(this HttpContext context, PathString path) { var request = context.Request; - if (!request.IsGetOrHeadMethod() || !request.IsEndpointPath(path)) + if (!request.IsGetOrHeadMethod() || !IsGraphQLEndpointPath(context, path)) { return true; } @@ -40,6 +42,13 @@ public static bool MayReachNitroApp(this HttpContext context, PathString path) && DefaultHttpResponseFormatter.PrefersHtml(acceptHeader.AcceptMediaTypes); } + // Inside a route group, path is relative to the group. On an endpoint, an empty slug route + // value identifies the endpoint path instead. + private static bool IsGraphQLEndpointPath(HttpContext context, PathString path) + => context.Request.IsEndpointPath(path) + || (context.GetEndpoint() is not null + && string.IsNullOrEmpty(context.GetRouteValue(NitroAppMiddleware.SlugRouteValueName)?.ToString())); + public static string? TryGetCostSwitch(this HttpContext context) { var headers = context.Request.Headers; diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/HotChocolate.AspNetCore.Pipeline.csproj b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/HotChocolate.AspNetCore.Pipeline.csproj index 10fcfa74bad..ad420c71668 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/HotChocolate.AspNetCore.Pipeline.csproj +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Pipeline/HotChocolate.AspNetCore.Pipeline.csproj @@ -20,6 +20,7 @@ + diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/HotChocolate.AspNetCore.csproj b/src/HotChocolate/AspNetCore/src/AspNetCore/HotChocolate.AspNetCore.csproj index 225d56f33cc..346fddee503 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/HotChocolate.AspNetCore.csproj +++ b/src/HotChocolate/AspNetCore/src/AspNetCore/HotChocolate.AspNetCore.csproj @@ -27,6 +27,7 @@ + diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/EmbeddedNitroAppTests.cs b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/EmbeddedNitroAppTests.cs new file mode 100644 index 00000000000..90c104feded --- /dev/null +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/EmbeddedNitroAppTests.cs @@ -0,0 +1,288 @@ +using System.Net; +using System.Net.Http.Headers; +using ChilliCream.Nitro.App; +using HotChocolate.AspNetCore.Tests.Utilities; +using HotChocolate.StarWars; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Yarp.ReverseProxy.Forwarder; + +namespace HotChocolate.AspNetCore; + +public class EmbeddedNitroAppTests : ServerTestBase +{ + public EmbeddedNitroAppTests(TestServerFactory serverFactory) + : base(serverFactory) + { + } + + [Fact] + public async Task MapGraphQL_WithEmbeddedNitroAssets_Serves_Nitro_And_GraphQL_On_The_Same_Path() + { + // arrange + var forwarder = new RecordingForwarder(); + var server = CreateServer(forwarder, e => e.MapGraphQL().WithEmbeddedNitroAssets()); + + // act + var nitro = await GetHtmlAsync(server, "/graphql/"); + var graphql = await server.PostAsync(new ClientQueryRequest { Query = "{ __typename }" }); + + // assert + Assert.Equal(HttpStatusCode.OK, nitro.StatusCode); + Assert.Equal("text/html", nitro.Content.Headers.ContentType?.MediaType); + Assert.Null(forwarder.DestinationPrefix); + Assert.Equal(HttpStatusCode.OK, graphql.StatusCode); + Assert.Equal("Query", graphql.Data?["__typename"]); + } + + [Fact] + public async Task MapNitroApp_WithEmbeddedNitroAssets_Serves_Nitro_And_Config() + { + // arrange + var forwarder = new RecordingForwarder(); + var server = CreateServer( + forwarder, + e => e.MapNitroApp().WithOptions(o => o.Title = "Embedded").WithEmbeddedNitroAssets()); + + // act + var nitro = await GetHtmlAsync(server, "/graphql/ui/"); + var config = await server.CreateClient().GetStringAsync( + "/graphql/ui/nitro-config.json", + TestContext.Current.CancellationToken); + + // assert + Assert.Equal(HttpStatusCode.OK, nitro.StatusCode); + Assert.Equal("text/html", nitro.Content.Headers.ContentType?.MediaType); + Assert.Null(forwarder.DestinationPrefix); + Assert.Contains("\"title\":\"Embedded\"", config); + } + + [Fact] + public async Task MapGraphQL_WithEmbeddedNitroAssets_In_MapGroup_Serves_Nitro_And_GraphQL() + { + // arrange + var forwarder = new RecordingForwarder(); + var server = CreateServer(forwarder, e => e.MapGroup("/api").MapGraphQL().WithEmbeddedNitroAssets()); + + // act + var nitro = await GetHtmlAsync(server, "/api/graphql/"); + var graphql = await server.PostAsync( + new ClientQueryRequest { Query = "{ __typename }" }, + "/api/graphql"); + + // assert + Assert.Equal(HttpStatusCode.OK, nitro.StatusCode); + Assert.Equal("text/html", nitro.Content.Headers.ContentType?.MediaType); + Assert.Null(forwarder.DestinationPrefix); + Assert.Equal("Query", graphql.Data?["__typename"]); + } + + [Fact] + public async Task MapGroup_WithEmbeddedNitroAssets_Serves_Nitro_For_Every_Endpoint_In_The_Group() + { + // arrange + var forwarder = new RecordingForwarder(); + var server = CreateServer( + forwarder, + e => + { + var group = e.MapGroup("/dev").WithEmbeddedNitroAssets(); + group.MapGraphQL(); + group.MapNitroApp("/ui"); + }); + + // act + var graphQLNitro = await GetHtmlAsync(server, "/dev/graphql/"); + var nitroApp = await GetHtmlAsync(server, "/dev/ui/"); + + // assert + Assert.Equal(HttpStatusCode.OK, graphQLNitro.StatusCode); + Assert.Equal(HttpStatusCode.OK, nitroApp.StatusCode); + Assert.Null(forwarder.DestinationPrefix); + } + + [Fact] + public async Task MapGroup_WithEmbeddedNitroAssets_Wins_Over_Schema_Wide_ServeMode() + { + // arrange + var forwarder = new RecordingForwarder(); + var server = ServerFactory.Create( + services => services + .AddSingleton(forwarder) + .AddRouting() + .AddGraphQLServer() + .AddStarWarsTypes() + .AddStarWarsRepositories() + .ModifyServerOptions(o => o.Tool.ServeMode = ServeMode.Insider), + app => app + .UseRouting() + .UseEndpoints(e => e.MapGroup("/dev").WithEmbeddedNitroAssets().MapGraphQL())); + + // act + var nitro = await GetHtmlAsync(server, "/dev/graphql/"); + + // assert + Assert.Equal(HttpStatusCode.OK, nitro.StatusCode); + Assert.Null(forwarder.DestinationPrefix); + } + + [Fact] + public async Task MapGraphQL_Middleware_Does_Not_Proxy_Nitro_For_Other_Endpoints_With_A_Slug() + { + // arrange + var forwarder = new RecordingForwarder(); + var server = ServerFactory.Create( + services => services + .AddSingleton(forwarder) + .AddRouting() + .AddGraphQLServer() + .AddStarWarsTypes() + .AddStarWarsRepositories(), + app => app + .UseRouting() + .MapGraphQL("/graphql", ISchemaDefinition.DefaultName) + .UseEndpoints(e => e.MapGet("/posts/{slug}", () => "post"))); + + // act + await GetHtmlAsync(server, "/posts/hello"); + var postDestination = forwarder.DestinationPrefix; + var nitro = await GetHtmlAsync(server, "/graphql/"); + + // assert + Assert.Null(postDestination); + Assert.Equal(HttpStatusCode.OK, nitro.StatusCode); + Assert.Equal("https://cdn.chillicream.com/web/latest/", forwarder.DestinationPrefix); + } + + [Theory] + [InlineData("")] + [InlineData("/api")] + public async Task MapGraphQL_Does_Not_Serve_Nitro_To_GraphQL_Clients(string groupPrefix) + { + // arrange + var forwarder = new RecordingForwarder(); + var server = CreateServer( + forwarder, + e => + { + var target = groupPrefix.Length == 0 ? e : e.MapGroup(groupPrefix); + target.MapGraphQL(); + }); + + // act + var withSlash = await GetAsync(server, groupPrefix + "/graphql/", "application/graphql-response+json"); + var withoutSlash = await GetAsync(server, groupPrefix + "/graphql", "application/graphql-response+json"); + var clientDestination = forwarder.DestinationPrefix; + var browser = await GetHtmlAsync(server, groupPrefix + "/graphql/"); + + // assert + Assert.Equal(HttpStatusCode.NotFound, withSlash.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, withoutSlash.StatusCode); + Assert.Null(clientDestination); + Assert.Equal(HttpStatusCode.OK, browser.StatusCode); + Assert.Equal("https://cdn.chillicream.com/web/latest/", forwarder.DestinationPrefix); + } + + [Fact] + public async Task MapGraphQL_With_ServeMode_Embedded_Still_Serves_Embedded_Nitro() + { + // arrange + var forwarder = new RecordingForwarder(); + var server = CreateServer( + forwarder, + e => e.MapGraphQL().WithOptions(o => o.Tool.ServeMode = ServeMode.Embedded)); + + // act + var nitro = await GetHtmlAsync(server, "/graphql/"); + + // assert + Assert.Equal(HttpStatusCode.OK, nitro.StatusCode); + Assert.Equal("text/html", nitro.Content.Headers.ContentType?.MediaType); + Assert.Null(forwarder.DestinationPrefix); + } + + [Fact] + public async Task MapGraphQL_Without_Embedded_Proxies_Nitro_From_The_Cdn() + { + // arrange + var forwarder = new RecordingForwarder(); + var server = CreateServer(forwarder, e => e.MapGraphQL()); + + // act + var nitro = await GetHtmlAsync(server, "/graphql/"); + + // assert + Assert.Equal(HttpStatusCode.OK, nitro.StatusCode); + Assert.Equal("https://cdn.chillicream.com/web/latest/", forwarder.DestinationPrefix); + } + + [Fact] + public async Task WithOptions_Version_After_WithEmbeddedNitroAssets_Proxies_That_Version() + { + // arrange + var forwarder = new RecordingForwarder(); + var server = CreateServer( + forwarder, + e => e.MapNitroApp() + .WithEmbeddedNitroAssets() + .WithOptions(o => o.ServeMode = ServeMode.Version("5.0.8"))); + + // act + var nitro = await GetHtmlAsync(server, "/graphql/ui/"); + + // assert + Assert.Equal(HttpStatusCode.OK, nitro.StatusCode); + Assert.Equal("https://cdn.chillicream.com/web/5.0.8/", forwarder.DestinationPrefix); + } + + private TestServer CreateServer(RecordingForwarder forwarder, Action configure) + => ServerFactory.Create( + services => services + .AddSingleton(forwarder) + .AddRouting() + .AddGraphQLServer() + .AddStarWarsTypes() + .AddStarWarsRepositories(), + app => app + .UseRouting() + .UseEndpoints(configure)); + + private static Task GetHtmlAsync(TestServer server, string path) + => GetAsync(server, path, "text/html"); + + private static Task GetAsync(TestServer server, string path, string accept) + { + var request = new HttpRequestMessage(HttpMethod.Get, path); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(accept)); + return server.CreateClient().SendAsync(request, TestContext.Current.CancellationToken); + } + + private sealed class RecordingForwarder : IHttpForwarder + { + public string? DestinationPrefix { get; private set; } + + public ValueTask SendAsync( + HttpContext context, + string destinationPrefix, + HttpMessageInvoker httpClient, + ForwarderRequestConfig requestConfig, + HttpTransformer transformer, + CancellationToken cancellationToken) + { + DestinationPrefix = destinationPrefix; + context.Response.StatusCode = StatusCodes.Status200OK; + return ValueTask.FromResult(ForwarderError.None); + } + + public ValueTask SendAsync( + HttpContext context, + string destinationPrefix, + HttpMessageInvoker httpClient, + ForwarderRequestConfig requestConfig, + HttpTransformer transformer) + => SendAsync(context, destinationPrefix, httpClient, requestConfig, transformer, default); + } +} diff --git a/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/Extensions/HotChocolateAzureFunctionServiceCollectionExtensions.cs b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/Extensions/HotChocolateAzureFunctionServiceCollectionExtensions.cs index 7cb1fab0904..cb20f8cde6b 100644 --- a/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/Extensions/HotChocolateAzureFunctionServiceCollectionExtensions.cs +++ b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/Extensions/HotChocolateAzureFunctionServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using ChilliCream.Nitro.App; +using ChilliCream.Nitro.App.Embed; using HotChocolate; using HotChocolate.AspNetCore; using HotChocolate.AzureFunctions; @@ -8,7 +9,6 @@ using Microsoft.AspNetCore.Http.Features; using Microsoft.Azure.WebJobs.Host.Config; using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Options; using MiddlewareFactory = HotChocolate.AspNetCore.MiddlewareFactory; @@ -104,7 +104,10 @@ private static IServiceCollection AddAzureFunctionsGraphQLRequestExecutor( .Use(MiddlewareFactory.CreateHttpGetSchemaMiddleware(executor, serverOptions, path, MiddlewareRoutingType.Integrated)) .UseWhen( context => context.MayReachNitroApp(path), - branch => branch.UseNitroApp(path, serverOptions.Tool)) + branch => branch.Use(NitroAppMiddleware.Create( + serverOptions.Tool, + path, + embeddedAssetSource: EmbeddedNitroApp.AssetSource))) .Use(MiddlewareFactory.CreateHttpUnsupportedRequestMiddleware( executor, serverOptions, @@ -139,46 +142,4 @@ public static IRequestExecutorBuilder ModifyFunctionOptions( builder.ModifyServerOptions(configure); return builder; } - - private static PipelineBuilder UseNitroApp( - this PipelineBuilder requestPipeline, - PathString path, - NitroAppOptions options) - { - ArgumentNullException.ThrowIfNull(requestPipeline); - - path = path.ToString().TrimEnd('/'); - var fileProvider = CreateFileProvider(); - var forwarderAccessor = new HttpForwarderAccessor(); - - return requestPipeline - .Use(next => - { - var middleware = new NitroAppOptionsFileMiddleware(next, path, options); - return middleware.Invoke; - }) - .Use(next => - { - var middleware = new NitroAppCdnMiddleware(next, path, forwarderAccessor, options); - return middleware.Invoke; - }) - .Use(next => - { - var middleware = new NitroAppDefaultFileMiddleware(next, fileProvider, path, options); - return middleware.Invoke; - }) - .Use(next => - { - var middleware = new NitroAppStaticFileMiddleware(next, fileProvider, path, options); - return middleware.Invoke; - }); - } - - private static IFileProvider CreateFileProvider() - { - var type = typeof(NitroAppStaticFileMiddleware); - var resourceNamespace = type.Namespace + ".Resources"; - - return new EmbeddedFileProvider(type.Assembly, resourceNamespace); - } } diff --git a/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/HotChocolate.AzureFunctions.csproj b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/HotChocolate.AzureFunctions.csproj index b02bcc7e998..f833fb309b3 100644 --- a/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/HotChocolate.AzureFunctions.csproj +++ b/src/HotChocolate/AzureFunctions/src/HotChocolate.AzureFunctions/HotChocolate.AzureFunctions.csproj @@ -19,6 +19,7 @@ + diff --git a/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/Helpers/TestHttpRequestDataHelper.cs b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/Helpers/TestHttpRequestDataHelper.cs index a1dd32d736e..a181f90cb6e 100644 --- a/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/Helpers/TestHttpRequestDataHelper.cs +++ b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/Helpers/TestHttpRequestDataHelper.cs @@ -2,7 +2,6 @@ using Microsoft.Azure.Functions.Worker.Http; using static HotChocolate.AzureFunctions.Tests.Helpers.TestHttpContextHelper; using static Microsoft.Net.Http.Headers.HeaderNames; -using IO = System.IO; namespace HotChocolate.AzureFunctions.IsolatedProcess.Tests.Helpers; @@ -31,7 +30,7 @@ public static HttpRequestData NewNitroHttpRequestData( HttpRequestData httpRequestData = new MockHttpRequestData( new MockFunctionContext(serviceProvider), HttpMethods.Get, - new Uri(IO.Path.Combine(DefaultAzFuncGraphQLUri.ToString(), path))); + new Uri(DefaultAzFuncGraphQLUri.ToString().TrimEnd('/') + "/" + path)); //Ensure we accept Text/Html for Nitro requests... httpRequestData.Headers.Add(Accept, TestConstants.DefaultNitroContentType); diff --git a/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/IsolatedProcessEndToEndTests.cs b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/IsolatedProcessEndToEndTests.cs index c96bfabda27..d4165de9a3a 100644 --- a/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/IsolatedProcessEndToEndTests.cs +++ b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.IsolatedProcess.Tests/IsolatedProcessEndToEndTests.cs @@ -235,6 +235,45 @@ public async Task AzFuncIsolatedProcess_NitroTestAsync() Assert.True(resultContent.Contains("")); } + [Theory] + [InlineData("", " b.AddQueryType( + d => d.Name("Query").Field("person").Resolve("Luke Skywalker"))) + .Build(); + + var requestExecutor = host.Services.GetRequiredService(); + var httpRequestData = TestHttpRequestDataHelper.NewNitroHttpRequestData(host.Services, path); + + var httpResponseData = await requestExecutor.ExecuteAsync(httpRequestData); + + var resultContent = await ReadResponseAsStringAsync(httpResponseData); + Assert.Equal(HttpStatusCode.OK, httpResponseData.StatusCode); + Assert.Contains(expectedContent, resultContent); + } + + [Fact] + public async Task AzFuncIsolatedProcess_NitroDisabledTestAsync() + { + var host = new MockIsolatedProcessHostBuilder() + .AddGraphQLFunction( + b => b + .AddQueryType(d => d.Name("Query").Field("person").Resolve("Luke Skywalker")) + .ModifyFunctionOptions(o => o.Tool.Enable = false)) + .Build(); + + var requestExecutor = host.Services.GetRequiredService(); + var httpRequestData = TestHttpRequestDataHelper.NewNitroHttpRequestData(host.Services, "index.html"); + + var httpResponseData = await requestExecutor.ExecuteAsync(httpRequestData); + + Assert.Equal(HttpStatusCode.NotFound, httpResponseData.StatusCode); + } + [Theory] [InlineData(HttpTransportVersion.Legacy, HttpStatusCode.NotFound, null)] [InlineData(HttpTransportVersion.Draft20250508, HttpStatusCode.NotFound, null)] diff --git a/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/Helpers/TestHttpContextHelper.cs b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/Helpers/TestHttpContextHelper.cs index 4bf82f03322..8dfc7e63ba8 100644 --- a/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/Helpers/TestHttpContextHelper.cs +++ b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/Helpers/TestHttpContextHelper.cs @@ -3,7 +3,6 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Microsoft.Net.Http.Headers; -using IO = System.IO; namespace HotChocolate.AzureFunctions.Tests.Helpers; @@ -31,9 +30,9 @@ public static HttpContext NewGraphQLHttpContext(string query) return httpContext; } - public static HttpContext NewNitroHttpContext() + public static HttpContext NewNitroHttpContext(string path = "index.html") { - var uri = new Uri(IO.Path.Combine(DefaultAzFuncGraphQLUri.ToString(), "index.html")); + var uri = new Uri(DefaultAzFuncGraphQLUri.ToString().TrimEnd('/') + "/" + path); var httpContext = new DefaultHttpContext(); diff --git a/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/InProcessEndToEndTests.cs b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/InProcessEndToEndTests.cs index c5ec61e4e01..a60ba4f550c 100644 --- a/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/InProcessEndToEndTests.cs +++ b/src/HotChocolate/AzureFunctions/test/HotChocolate.AzureFunctions.Tests/InProcessEndToEndTests.cs @@ -181,6 +181,49 @@ public async Task AzFuncInProcess_NitroTestAsync() Assert.True(resultContent.Contains("")); } + [Theory] + [InlineData("", " d.Name("Query").Field("person").Resolve("Luke Skywalker")); + + var requestExecutor = hostBuilder.BuildServiceProvider().GetRequiredService(); + var httpContext = TestHttpContextHelper.NewNitroHttpContext(path); + + await requestExecutor.ExecuteAsync(httpContext.Request); + + var resultContent = await httpContext.ReadResponseContentAsync(); + Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode); + Assert.Contains(expectedContent, resultContent); + } + + [Fact] + public async Task AzFuncInProcess_NitroDisabledTestAsync() + { + var hostBuilder = new MockInProcessFunctionsHostBuilder(); + + hostBuilder.Services.AddHttpContextAccessor(); + + hostBuilder + .AddGraphQLFunction() + .AddQueryType(d => d.Name("Query").Field("person").Resolve("Luke Skywalker")) + .ModifyFunctionOptions(o => o.Tool.Enable = false); + + var requestExecutor = hostBuilder.BuildServiceProvider().GetRequiredService(); + var httpContext = TestHttpContextHelper.NewNitroHttpContext(); + + await requestExecutor.ExecuteAsync(httpContext.Request); + + Assert.Equal(StatusCodes.Status404NotFound, httpContext.Response.StatusCode); + } + [Theory] [InlineData(HttpTransportVersion.Legacy, StatusCodes.Status404NotFound, null)] [InlineData(HttpTransportVersion.Draft20250508, StatusCodes.Status404NotFound, null)] diff --git a/website/content/docs/hotchocolate/server/endpoints.md b/website/content/docs/hotchocolate/server/endpoints.md index 75cb24b31cd..e0ad8cc28f7 100644 --- a/website/content/docs/hotchocolate/server/endpoints.md +++ b/website/content/docs/hotchocolate/server/endpoints.md @@ -139,6 +139,14 @@ app.UseEndpoints(endpoints => This makes Nitro accessible via a web browser at the `/graphql/ui` endpoint. +To serve the Nitro app that ships in the `ChilliCream.Nitro.App.Embed` package instead of serving it from the Nitro CDN, call `WithEmbeddedNitroAssets()`. This works on `MapNitroApp()`, on `MapGraphQL()` and on route groups. + +```csharp +endpoints.MapNitroApp("/graphql/ui").WithEmbeddedNitroAssets(); +``` + +[Learn more about serving the embedded Nitro app](../../nitro/integrations/hot-chocolate.md#serve-the-embedded-nitro-app) + ## NitroAppOptions You can configure Nitro using `NitroAppOptions`. diff --git a/website/content/docs/nitro/integrations/hot-chocolate.md b/website/content/docs/nitro/integrations/hot-chocolate.md index 83c2dd513ed..64d11cf1a3a 100644 --- a/website/content/docs/nitro/integrations/hot-chocolate.md +++ b/website/content/docs/nitro/integrations/hot-chocolate.md @@ -38,12 +38,10 @@ endpoints # Serve Modes -The `ServeMode` property controls which version of Nitro to serve. The default mode is `Latest`, serving the most recent version of Nitro from a CDN. -You can also serve the embedded version (`Embedded`) of Nitro, which is included in the package. +The `ServeMode` property controls which version of Nitro to serve from the Nitro CDN. The default mode is `Latest`, serving the most recent version of Nitro. -- `Latest`: Serves the latest version of Nitro from a CDN. -- `Insider`: Serves the insider version of Nitro from a CDN, allowing preview of upcoming features. -- `Embedded`: Serves the embedded version of Nitro that comes with the package. +- `Latest`: Serves the latest version of Nitro from the CDN. +- `Insider`: Serves the insider version of Nitro from the CDN, allowing preview of upcoming features. - `Version(string version)`: Serves a specific version of Nitro from the CDN. Depending on your environment or preferences, you can choose the appropriate mode: @@ -51,9 +49,33 @@ Depending on your environment or preferences, you can choose the appropriate mod ```csharp endpoints .MapNitroApp() - .WithOptions(o => o.ServeMode = ServeMode.Embedded); + .WithOptions(o => o.ServeMode = ServeMode.Insider); ``` +# Serve the Embedded Nitro App + +Instead of serving Nitro from the CDN, you can serve the version of Nitro that ships in the `ChilliCream.Nitro.App.Embed` package. Call `WithEmbeddedNitroAssets()` on the endpoint: + +```csharp +app.UseEndpoints(endpoints => +{ + endpoints.MapGraphQL().WithEmbeddedNitroAssets(); + endpoints.MapNitroApp("/my-graphql-ui").WithEmbeddedNitroAssets(); +}); +``` + +On `MapGraphQL()`, Nitro stays on the same path as the GraphQL endpoint. Call `WithEmbeddedNitroAssets()` on a route group to serve the embedded Nitro app on every endpoint in the group: + +```csharp +var api = app.MapGroup("/api").WithEmbeddedNitroAssets(); +api.MapGraphQL(); +``` + +All other Nitro options work the same way for the embedded Nitro app. As with other options that set the serve mode, the last call wins: calling `WithOptions(o => o.ServeMode = ServeMode.Version("..."))` after `WithEmbeddedNitroAssets()` serves that version from the CDN. + +> [!NOTE] +> Setting `ServeMode.Embedded` still serves the embedded Nitro app, but it will be removed in Hot Chocolate 17. From Hot Chocolate 17, reference the `ChilliCream.Nitro.App.Embed` package and call `WithEmbeddedNitroAssets()`. + # Configuration Options You can tailor Nitro to your needs by setting various options via `NitroAppOptions`. You can specify these options using the `WithOptions()` method in both `MapGraphQL()` and `MapNitroApp()` methods. @@ -61,7 +83,7 @@ You can tailor Nitro to your needs by setting various options via `NitroAppOptio | Property | Type | Description | | ------------------------------ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | Enable | `bool` | If `false`, disables the Nitro tool. | -| ServeMode | `ServeMode` | Defines how Nitro is served. Options include `Latest` (default), `Insider`, `Embedded`, and `Version(string version)`. | +| ServeMode | `ServeMode` | Defines which version of Nitro is served from the CDN: `Latest` (default), `Insider`, or `Version(string version)`. See `WithEmbeddedNitroAssets()`. | | Title | `string` | Specifies the title of the Nitro page. | | Document | `string` | Specifies the default document content. | | IncludeCookies | `bool?` | If `true`, includes cookies in the HTTP call to the GraphQL backend. |