From 69ab683932fae448901ed29e7de8b6de507258e5 Mon Sep 17 00:00:00 2001 From: Kevin Carroll Date: Sun, 18 Sep 2022 14:46:40 -0700 Subject: [PATCH 1/4] added support for GET requests --- ...{invokePrBuild.ps1 => invoke-pr-build.ps1} | 0 build/nuget-package-readme.md | 7 +- src/graphql-aspnet/Constants.cs | 24 ++++ .../DefaultGraphQLHttpProcessor{TSchema}.cs | 56 ++++++++- .../Variables/InputVariableCollection.cs | 3 + .../Web/GetRequestTests.cs | 113 ++++++++++++++++++ 6 files changed, 194 insertions(+), 9 deletions(-) rename build/{invokePrBuild.ps1 => invoke-pr-build.ps1} (100%) create mode 100644 src/tests/graphql-aspnet-tests/Web/GetRequestTests.cs diff --git a/build/invokePrBuild.ps1 b/build/invoke-pr-build.ps1 similarity index 100% rename from build/invokePrBuild.ps1 rename to build/invoke-pr-build.ps1 diff --git a/build/nuget-package-readme.md b/build/nuget-package-readme.md index fb222da37..0824439e9 100644 --- a/build/nuget-package-readme.md +++ b/build/nuget-package-readme.md @@ -1,13 +1,8 @@ ## GraphQL ASP.NET -
- ### Documentation: [https://graphql-aspnet.github.io](https://graphql-aspnet.github.io) -
- -GraphQL ASP.NET is a fully featured graphql library that utilizes a controller/action programming model similar to ASP.NET. - +GraphQL ASP.NET is a fully featured graphql library that utilizes a controller/action programming model similar to ASP.NET. **This Controller** diff --git a/src/graphql-aspnet/Constants.cs b/src/graphql-aspnet/Constants.cs index 60404e85e..6b1d8326e 100644 --- a/src/graphql-aspnet/Constants.cs +++ b/src/graphql-aspnet/Constants.cs @@ -568,6 +568,30 @@ public static class Pipelines public const string DIRECTIVE_PIPELINE = "Directive Execution Pipeline"; } + /// + /// A collection of constants related to the web processing of a queryr. + /// + public static class Web + { + /// + /// The key in the query string representing the query text when + /// processing a GET request. + /// + public const string QUERYSTRING_QUERY_KEY = "query"; + + /// + /// The key in the query string representing the named operation when + /// processing a GET request. + /// + public const string QUERYSTRING_OPERATIONNAME_KEY = "operationName"; + + /// + /// The key in the query string representing the the json encoded variable collection + /// when processing a GET request. + /// + public const string QUERYSTRING_VARIABLES_KEY = "variables"; + } + /// /// Gets a URL pointing to the page of the graphql specification this library /// targets. This value is used as a base url for most validation rules to generate diff --git a/src/graphql-aspnet/Defaults/DefaultGraphQLHttpProcessor{TSchema}.cs b/src/graphql-aspnet/Defaults/DefaultGraphQLHttpProcessor{TSchema}.cs index d8f736fb9..000ef725a 100644 --- a/src/graphql-aspnet/Defaults/DefaultGraphQLHttpProcessor{TSchema}.cs +++ b/src/graphql-aspnet/Defaults/DefaultGraphQLHttpProcessor{TSchema}.cs @@ -26,6 +26,7 @@ namespace GraphQL.AspNet.Defaults using GraphQL.AspNet.Interfaces.Web; using GraphQL.AspNet.Logging.Extensions; using GraphQL.AspNet.Security.Web; + using GraphQL.AspNet.Variables; using GraphQL.AspNet.Web; using Microsoft.AspNetCore.Http; @@ -88,12 +89,62 @@ public DefaultGraphQLHttpProcessor( public virtual async Task Invoke(HttpContext context) { this.HttpContext = Validation.ThrowIfNullOrReturn(context, nameof(context)); - if (!string.Equals(context.Request.Method, nameof(HttpMethod.Post), StringComparison.OrdinalIgnoreCase)) + GraphQueryData queryData = null; + + if (string.Equals(context.Request.Method, nameof(HttpMethod.Post), StringComparison.OrdinalIgnoreCase)) + { + queryData = await this.DecodePostRequest(context); + } + else if (string.Equals(context.Request.Method, nameof(HttpMethod.Get), StringComparison.OrdinalIgnoreCase)) + { + queryData = this.DecodeGetRequest(context); + } + else { await this.WriteStatusCodeResponse(HttpStatusCode.BadRequest, ERROR_USE_POST, context.RequestAborted).ConfigureAwait(false); return; } + await this.SubmitGraphQLQuery(queryData, context.RequestAborted).ConfigureAwait(false); + } + + /// + /// Attempts to extract the necessary keys from the query string of the + /// and create a object that can be processed by the GraphQL runtime. + /// + /// The http context to deserialize. + /// GraphQueryData. + private GraphQueryData DecodeGetRequest(HttpContext context) + { + var queryString = string.Empty; + var variables = string.Empty; + var operationName = string.Empty; + + if (context.Request.Query.ContainsKey(Constants.Web.QUERYSTRING_QUERY_KEY)) + queryString = context.Request.Query[Constants.Web.QUERYSTRING_QUERY_KEY]; + + if (context.Request.Query.ContainsKey(Constants.Web.QUERYSTRING_OPERATIONNAME_KEY)) + operationName = context.Request.Query[Constants.Web.QUERYSTRING_OPERATIONNAME_KEY]; + + if (context.Request.Query.ContainsKey(Constants.Web.QUERYSTRING_VARIABLES_KEY)) + variables = context.Request.Query[Constants.Web.QUERYSTRING_VARIABLES_KEY]; + + return new GraphQueryData() + { + Query = queryString, + OperationName = operationName, + Variables = InputVariableCollection.FromJsonDocument(variables), + }; + } + + /// + /// Attempts to deserialize the POST body of the + /// into a object that can be processed by the GraphQL runtime. + /// + /// The http context to deserialize. + /// GraphQueryData. + protected async Task DecodePostRequest(HttpContext context) + { // accepting a parsed object causes havoc with any variables collection // ------ // By default: @@ -108,8 +159,7 @@ public virtual async Task Invoke(HttpContext context) options.AllowTrailingCommas = true; options.ReadCommentHandling = JsonCommentHandling.Skip; - var data = await JsonSerializer.DeserializeAsync(context.Request.Body, options).ConfigureAwait(false); - await this.SubmitGraphQLQuery(data, context.RequestAborted).ConfigureAwait(false); + return await JsonSerializer.DeserializeAsync(context.Request.Body, options).ConfigureAwait(false); } /// diff --git a/src/graphql-aspnet/Variables/InputVariableCollection.cs b/src/graphql-aspnet/Variables/InputVariableCollection.cs index c6f5fa523..9de3c277c 100644 --- a/src/graphql-aspnet/Variables/InputVariableCollection.cs +++ b/src/graphql-aspnet/Variables/InputVariableCollection.cs @@ -32,6 +32,9 @@ public class InputVariableCollection : IInputVariableCollection /// IInputVariableCollection. public static InputVariableCollection FromJsonDocument(string jsonDocument) { + if (string.IsNullOrWhiteSpace(jsonDocument)) + return InputVariableCollection.Empty; + var options = new JsonSerializerOptions() { AllowTrailingCommas = true, diff --git a/src/tests/graphql-aspnet-tests/Web/GetRequestTests.cs b/src/tests/graphql-aspnet-tests/Web/GetRequestTests.cs new file mode 100644 index 000000000..2f2a8aa00 --- /dev/null +++ b/src/tests/graphql-aspnet-tests/Web/GetRequestTests.cs @@ -0,0 +1,113 @@ +// ************************************************************* +// project: graphql-aspnet +// -- +// repo: https://github.com/graphql-aspnet +// docs: https://graphql-aspnet.github.io +// -- +// License: MIT +// ************************************************************* + +namespace GraphQL.AspNet.Tests.Web +{ + using System.Collections.Generic; + using System.IO; + using System.Threading.Tasks; + using System.Web; + using GraphQL.AspNet.Defaults; + using GraphQL.AspNet.Schemas; + using GraphQL.AspNet.Tests.Framework; + using GraphQL.AspNet.Tests.Framework.CommonHelpers; + using GraphQL.AspNet.Tests.Web.CancelTokenTestData; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Http.Internal; + using Microsoft.Extensions.DependencyInjection; + using NUnit.Framework; + + [TestFixture] + public class GetRequestTests + { + public static List _queryStringTests; + + static GetRequestTests() + { + _queryStringTests = new List(); + + var queryText = "{add(a: 5, b: 3)}"; + var queryString = $"?{Constants.Web.QUERYSTRING_QUERY_KEY}={queryText}"; + _queryStringTests.Add(new object[] { queryString, 8 }); + + queryText = HttpUtility.UrlEncode("{add(a: 5, b: 3)}"); + queryString = $"?{Constants.Web.QUERYSTRING_QUERY_KEY}={queryText}"; + _queryStringTests.Add(new object[] { queryString, 8 }); + + queryText = "query ($i: Int!){add(a: 5, b: $i)}"; + var variables = "{\"i\": 3}"; + queryString = $"?{Constants.Web.QUERYSTRING_QUERY_KEY}={queryText}&{Constants.Web.QUERYSTRING_VARIABLES_KEY}={variables}"; + _queryStringTests.Add(new object[] { queryString, 8 }); + + queryText = HttpUtility.UrlEncode("query ($i: Int!){add(a: 5, b: $i)}"); + variables = HttpUtility.UrlEncode("{\"i\": 3}"); + queryString = $"?{Constants.Web.QUERYSTRING_QUERY_KEY}={queryText}&{Constants.Web.QUERYSTRING_VARIABLES_KEY}={variables}"; + _queryStringTests.Add(new object[] { queryString, 8 }); + + queryText = HttpUtility.UrlEncode("query op1($i: Int!){add(a: 5, b: $i)} query op2{add(a: 5, b:5)}"); + var operation = "op1"; + variables = HttpUtility.UrlEncode("{\"i\": 3}"); + queryString = $"?{Constants.Web.QUERYSTRING_QUERY_KEY}={queryText}&{Constants.Web.QUERYSTRING_VARIABLES_KEY}={variables}&{Constants.Web.QUERYSTRING_OPERATIONNAME_KEY}={operation}"; + _queryStringTests.Add(new object[] { queryString, 8 }); + + queryText = HttpUtility.UrlEncode("query op1_1($i: Int!){add(a: 5, b: $i)} query op2{add(a: 5, b:5)}"); + operation = HttpUtility.UrlEncode("op1_1"); + variables = HttpUtility.UrlEncode("{\"i\": 3}"); + queryString = $"?{Constants.Web.QUERYSTRING_QUERY_KEY}={queryText}&{Constants.Web.QUERYSTRING_VARIABLES_KEY}={variables}&{Constants.Web.QUERYSTRING_OPERATIONNAME_KEY}={operation}"; + _queryStringTests.Add(new object[] { queryString, 8 }); + } + + [TestCaseSource(nameof(_queryStringTests))] + public async Task GETRequest_QueryStringTests(string queryText, int result) + { + var serverBuilder = new TestServerBuilder(); + serverBuilder.AddGraphController(); + serverBuilder.AddTransient>(); + serverBuilder.AddGraphQL((o) => + { + o.ExecutionOptions.QueryTimeout = null; + }); + + var server = serverBuilder.Build(); + + using var scope = server.ServiceProvider.CreateScope(); + var processor = scope.ServiceProvider.GetRequiredService>(); + + var httpContext = new DefaultHttpContext() + { + Response = + { + Body = new MemoryStream(), + }, + RequestServices = scope.ServiceProvider, + }; + + var request = httpContext.Request as DefaultHttpRequest; + request.Method = "GET"; + request.QueryString = new QueryString(queryText); + + await processor.Invoke(httpContext); + await httpContext.Response.Body.FlushAsync(); + + httpContext.Response.Body.Seek(0, SeekOrigin.Begin); + var reader = new StreamReader(httpContext.Response.Body); + var text = reader.ReadToEnd(); + + var expectedResult = @" + { + ""data"" : { + ""add"" : " + result + @" + } + }"; + + CommonAssertions.AreEqualJsonStrings(expectedResult, text); + Assert.AreEqual(200, httpContext.Response.StatusCode); + } + } +} \ No newline at end of file From 8985d146a4c3213c9746005b30f403729a5af319 Mon Sep 17 00:00:00 2001 From: Kevin Carroll Date: Sun, 18 Sep 2022 15:39:15 -0700 Subject: [PATCH 2/4] added support for query params on POST request, and GQL content-header --- src/graphql-aspnet/Constants.cs | 8 +- .../DefaultGraphQLHttpProcessor{TSchema}.cs | 66 ++++++- .../Web/GetRequestTests.cs | 163 ++++++++++++++++++ 3 files changed, 227 insertions(+), 10 deletions(-) diff --git a/src/graphql-aspnet/Constants.cs b/src/graphql-aspnet/Constants.cs index 6b1d8326e..2ee232d1f 100644 --- a/src/graphql-aspnet/Constants.cs +++ b/src/graphql-aspnet/Constants.cs @@ -569,7 +569,7 @@ public static class Pipelines } /// - /// A collection of constants related to the web processing of a queryr. + /// A collection of constants related to the web processing of a query. /// public static class Web { @@ -590,6 +590,12 @@ public static class Web /// when processing a GET request. /// public const string QUERYSTRING_VARIABLES_KEY = "variables"; + + /// + /// A 'content-type' header value that indicates the body of a POST request + /// should be treated entirely as a graphql query string. + /// + public const string GRAPHQL_CONTENT_TYPE_HEADER = "application/graphql"; } /// diff --git a/src/graphql-aspnet/Defaults/DefaultGraphQLHttpProcessor{TSchema}.cs b/src/graphql-aspnet/Defaults/DefaultGraphQLHttpProcessor{TSchema}.cs index 000ef725a..990d0745f 100644 --- a/src/graphql-aspnet/Defaults/DefaultGraphQLHttpProcessor{TSchema}.cs +++ b/src/graphql-aspnet/Defaults/DefaultGraphQLHttpProcessor{TSchema}.cs @@ -10,9 +10,11 @@ namespace GraphQL.AspNet.Defaults { using System; + using System.IO; using System.Net; using System.Net.Http; using System.Security.Claims; + using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -60,6 +62,12 @@ public class DefaultGraphQLHttpProcessor : IGraphQLHttpProcessor protected const string ERROR_NO_REQUEST_CREATED = "GraphQL Operation Request is null. Unable to execute the query."; + /// + /// An error message format constant, in english, providing the text to return to teh caller + /// when an attempt to deserailize a json payload on a POST request fails. + /// + protected const string ERROR_SERIALIZATION_ISSUE_FORMAT = "Unable to deserialize the POST body as a JSON object: {0}"; + private readonly IGraphEventLogger _logger; private readonly TSchema _schema; private readonly IGraphQLRuntime _runtime; @@ -93,11 +101,20 @@ public virtual async Task Invoke(HttpContext context) if (string.Equals(context.Request.Method, nameof(HttpMethod.Post), StringComparison.OrdinalIgnoreCase)) { - queryData = await this.DecodePostRequest(context); + try + { + queryData = await this.DecodeAsPostRequest(context); + } + catch (JsonException jex) + { + var message = string.Format(ERROR_SERIALIZATION_ISSUE_FORMAT, jex.Message); + await this.WriteStatusCodeResponse(HttpStatusCode.BadRequest, message, context.RequestAborted).ConfigureAwait(false); + return; + } } else if (string.Equals(context.Request.Method, nameof(HttpMethod.Get), StringComparison.OrdinalIgnoreCase)) { - queryData = this.DecodeGetRequest(context); + queryData = this.DecodeAsGetRequest(context); } else { @@ -114,7 +131,7 @@ public virtual async Task Invoke(HttpContext context) /// /// The http context to deserialize. /// GraphQueryData. - private GraphQueryData DecodeGetRequest(HttpContext context) + private GraphQueryData DecodeAsGetRequest(HttpContext context) { var queryString = string.Empty; var variables = string.Empty; @@ -143,17 +160,43 @@ private GraphQueryData DecodeGetRequest(HttpContext context) /// /// The http context to deserialize. /// GraphQueryData. - protected async Task DecodePostRequest(HttpContext context) + protected async Task DecodeAsPostRequest(HttpContext context) { - // accepting a parsed object causes havoc with any variables collection + // if a "query" parameter appears in the query string treat the request like a get + // request and parse parameters from the query string + // ----- + // See: https://graphql.org/learn/serving-over-http/#http-methods-headers-and-body + // ----- + if (context.Request.Query.ContainsKey(Constants.Web.QUERYSTRING_QUERY_KEY)) + return this.DecodeAsGetRequest(context); + + // if the content-type is set to graphql treat + // the whole POST bady as the query string + // ----- + // See: https://graphql.org/learn/serving-over-http/#http-methods-headers-and-body + // ----- + if (context.Request.ContentType == Constants.Web.GRAPHQL_CONTENT_TYPE_HEADER) + { + using var reader = new StreamReader(context.Request.Body, Encoding.UTF8, true, 1024, true); + var query = await reader.ReadToEndAsync(); + return new GraphQueryData() + { + Query = query, + }; + } + + // accepting a preparsed object (i.e. allowing .NET to decode it) + // can cause havoc with any variables collection // ------ // By default: // netcoreapp2.2 and older would auto parse to JObject (Newtonsoft) // netcoreapp3.0 and later will parse to JsonElement (System.Text.Json). + // + // and the developer may have some unexpected defaults configured as well // ------ - // in lue of supporting deserialization from both generic json object types - // we accept the raw data and parse the json document - // using System.Text.Json on all clients (netstandard2.0 compatiable) + // instead of supporting deserialization from both generic json object types + // we accept the raw text and parse the json document + // using System.Text.Json on all clients var options = new JsonSerializerOptions(); options.PropertyNameCaseInsensitive = true; options.AllowTrailingCommas = true; @@ -279,7 +322,7 @@ protected virtual IUserSecurityContext CreateUserSecurityContext() } /// - /// writes directly to the stream with the given status code + /// Writes directly to the stream with the given status code /// and message. /// /// The status code to deliver on the response. @@ -288,6 +331,11 @@ protected virtual IUserSecurityContext CreateUserSecurityContext() /// Task. protected async Task WriteStatusCodeResponse(HttpStatusCode statusCode, string message, CancellationToken cancelToken = default) { + if (_schema.Configuration.ResponseOptions.AppendServerHeader) + { + this.Response.Headers.Add(Constants.ServerInformation.SERVER_INFORMATION_HEADER, Constants.ServerInformation.ServerData); + } + this.Response.StatusCode = (int)statusCode; await this.Response.WriteAsync(message, cancelToken).ConfigureAwait(false); } diff --git a/src/tests/graphql-aspnet-tests/Web/GetRequestTests.cs b/src/tests/graphql-aspnet-tests/Web/GetRequestTests.cs index 2f2a8aa00..cf8491c0a 100644 --- a/src/tests/graphql-aspnet-tests/Web/GetRequestTests.cs +++ b/src/tests/graphql-aspnet-tests/Web/GetRequestTests.cs @@ -11,6 +11,8 @@ namespace GraphQL.AspNet.Tests.Web { using System.Collections.Generic; using System.IO; + using System.Text; + using System.Text.Json; using System.Threading.Tasks; using System.Web; using GraphQL.AspNet.Defaults; @@ -109,5 +111,166 @@ public async Task GETRequest_QueryStringTests(string queryText, int result) CommonAssertions.AreEqualJsonStrings(expectedResult, text); Assert.AreEqual(200, httpContext.Response.StatusCode); } + + [Test] + public async Task POSTRequest_TreatedAsGETRequest_WhenQueryStringPresent() + { + var serverBuilder = new TestServerBuilder(); + serverBuilder.AddGraphController(); + serverBuilder.AddTransient>(); + serverBuilder.AddGraphQL((o) => + { + o.ExecutionOptions.QueryTimeout = null; + }); + + var server = serverBuilder.Build(); + + using var scope = server.ServiceProvider.CreateScope(); + var processor = scope.ServiceProvider.GetRequiredService>(); + + var postBodyQuery = "{add(a: 5, b:5)}"; // post would render 10 + var queryStringQuery = "{add(a: 5, b:3)}"; // query string would render 8 + + // expected to parse 8 + var expectedResult = @" + { + ""data"" : { + ""add"" : 8 + } + }"; + + var requestData = new Dictionary() + { + { "query", postBodyQuery }, + }; + + var json = JsonSerializer.Serialize(requestData); + var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + var httpContext = new DefaultHttpContext() + { + Request = + { + Body = stream, + ContentLength = stream.Length, + }, + Response = + { + Body = new MemoryStream(), + }, + }; + + var request = httpContext.Request as DefaultHttpRequest; + request.Method = "POST"; + httpContext.RequestServices = scope.ServiceProvider; + + var queryText = $"?{Constants.Web.QUERYSTRING_QUERY_KEY}={queryStringQuery}"; + request.QueryString = new QueryString(queryText); + + await processor.Invoke(httpContext); + await httpContext.Response.Body.FlushAsync(); + + httpContext.Response.Body.Seek(0, SeekOrigin.Begin); + var reader = new StreamReader(httpContext.Response.Body); + var text = reader.ReadToEnd(); + + CommonAssertions.AreEqualJsonStrings(expectedResult, text); + Assert.AreEqual(200, httpContext.Response.StatusCode); + } + + [Test] + public async Task POSTRequest_WithGraphQLContentType_TreatsBodyAsQuery() + { + var serverBuilder = new TestServerBuilder(); + serverBuilder.AddGraphController(); + serverBuilder.AddTransient>(); + + var server = serverBuilder.Build(); + + using var scope = server.ServiceProvider.CreateScope(); + var processor = scope.ServiceProvider.GetRequiredService>(); + + var postBodyQuery = "{add(a: 5, b:3)}"; + var expectedResult = @" + { + ""data"" : { + ""add"" : 8 + } + }"; + + // use the query directly as the post body (not json encoded) + var stream = new MemoryStream(Encoding.UTF8.GetBytes(postBodyQuery)); + var httpContext = new DefaultHttpContext() + { + Request = + { + Body = stream, + ContentLength = stream.Length, + }, + Response = + { + Body = new MemoryStream(), + }, + }; + + var request = httpContext.Request as DefaultHttpRequest; + request.Method = "POST"; + request.ContentType = Constants.Web.GRAPHQL_CONTENT_TYPE_HEADER; + httpContext.RequestServices = scope.ServiceProvider; + + await processor.Invoke(httpContext); + await httpContext.Response.Body.FlushAsync(); + + httpContext.Response.Body.Seek(0, SeekOrigin.Begin); + var reader = new StreamReader(httpContext.Response.Body); + var text = reader.ReadToEnd(); + + CommonAssertions.AreEqualJsonStrings(expectedResult, text); + Assert.AreEqual(200, httpContext.Response.StatusCode); + } + + [Test] + public async Task POSTRequest_WithQueryAsBody_WithoutGraphQLContentType_Fails() + { + var serverBuilder = new TestServerBuilder(); + serverBuilder.AddGraphController(); + serverBuilder.AddTransient>(); + + var server = serverBuilder.Build(); + + using var scope = server.ServiceProvider.CreateScope(); + var processor = scope.ServiceProvider.GetRequiredService>(); + + var postBodyQuery = "{add(a: 5, b:3)}"; + + // use the query directly as the post body (not json encoded) + var stream = new MemoryStream(Encoding.UTF8.GetBytes(postBodyQuery)); + var httpContext = new DefaultHttpContext() + { + Request = + { + Body = stream, + ContentLength = stream.Length, + }, + Response = + { + Body = new MemoryStream(), + }, + }; + + var request = httpContext.Request as DefaultHttpRequest; + request.Method = "POST"; + httpContext.RequestServices = scope.ServiceProvider; + + // context will attempt to be deserialized as json and fail + // should return status 400 + await processor.Invoke(httpContext); + await httpContext.Response.Body.FlushAsync(); + + httpContext.Response.Body.Seek(0, SeekOrigin.Begin); + var reader = new StreamReader(httpContext.Response.Body); + var text = reader.ReadToEnd(); + + Assert.AreEqual(400, httpContext.Response.StatusCode); + } } } \ No newline at end of file From da2819b2298113fe32c31c28c8da04b0fe932e83 Mon Sep 17 00:00:00 2001 From: Kevin Carroll Date: Sun, 18 Sep 2022 15:45:20 -0700 Subject: [PATCH 3/4] spelling updates --- src/graphql-aspnet/Constants.cs | 2 +- .../Defaults/DefaultGraphQLHttpProcessor{TSchema}.cs | 4 ++-- src/tests/graphql-aspnet-tests/Web/GetRequestTests.cs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/graphql-aspnet/Constants.cs b/src/graphql-aspnet/Constants.cs index 2ee232d1f..ecb179195 100644 --- a/src/graphql-aspnet/Constants.cs +++ b/src/graphql-aspnet/Constants.cs @@ -595,7 +595,7 @@ public static class Web /// A 'content-type' header value that indicates the body of a POST request /// should be treated entirely as a graphql query string. /// - public const string GRAPHQL_CONTENT_TYPE_HEADER = "application/graphql"; + public const string GRAPHQL_CONTENT_TYPE_HEADER_VALUE = "application/graphql"; } /// diff --git a/src/graphql-aspnet/Defaults/DefaultGraphQLHttpProcessor{TSchema}.cs b/src/graphql-aspnet/Defaults/DefaultGraphQLHttpProcessor{TSchema}.cs index 990d0745f..4fc04f24e 100644 --- a/src/graphql-aspnet/Defaults/DefaultGraphQLHttpProcessor{TSchema}.cs +++ b/src/graphql-aspnet/Defaults/DefaultGraphQLHttpProcessor{TSchema}.cs @@ -171,11 +171,11 @@ protected async Task DecodeAsPostRequest(HttpContext context) return this.DecodeAsGetRequest(context); // if the content-type is set to graphql treat - // the whole POST bady as the query string + // the whole POST body as the query string // ----- // See: https://graphql.org/learn/serving-over-http/#http-methods-headers-and-body // ----- - if (context.Request.ContentType == Constants.Web.GRAPHQL_CONTENT_TYPE_HEADER) + if (context.Request.ContentType == Constants.Web.GRAPHQL_CONTENT_TYPE_HEADER_VALUE) { using var reader = new StreamReader(context.Request.Body, Encoding.UTF8, true, 1024, true); var query = await reader.ReadToEndAsync(); diff --git a/src/tests/graphql-aspnet-tests/Web/GetRequestTests.cs b/src/tests/graphql-aspnet-tests/Web/GetRequestTests.cs index cf8491c0a..eab5c163d 100644 --- a/src/tests/graphql-aspnet-tests/Web/GetRequestTests.cs +++ b/src/tests/graphql-aspnet-tests/Web/GetRequestTests.cs @@ -214,7 +214,7 @@ public async Task POSTRequest_WithGraphQLContentType_TreatsBodyAsQuery() var request = httpContext.Request as DefaultHttpRequest; request.Method = "POST"; - request.ContentType = Constants.Web.GRAPHQL_CONTENT_TYPE_HEADER; + request.ContentType = Constants.Web.GRAPHQL_CONTENT_TYPE_HEADER_VALUE; httpContext.RequestServices = scope.ServiceProvider; await processor.Invoke(httpContext); From b1b7ad54354cbaa12e57f2ed79b5b59e3cd725ee Mon Sep 17 00:00:00 2001 From: Kevin Carroll Date: Sat, 24 Sep 2022 14:54:38 -0700 Subject: [PATCH 4/4] WIP, added httpcontextparser to extract data from http context --- .../SchemaQueryHandlerConfiguration.cs | 6 +- .../DefaultGraphQLHttpProcessor{TSchema}.cs | 136 ++------ src/graphql-aspnet/GraphQueryData.cs | 9 +- .../Exceptions/HttpContextParsingException.cs | 41 +++ .../Web/GraphQueryHandler{TSchema}.cs | 2 +- src/graphql-aspnet/Web/HttpContextParser.cs | 194 +++++++++++ src/graphql-aspnet/graphql-aspnet.csproj | 2 +- .../Web/HttpContextParserTests.cs | 312 ++++++++++++++++++ 8 files changed, 582 insertions(+), 120 deletions(-) create mode 100644 src/graphql-aspnet/Web/Exceptions/HttpContextParsingException.cs create mode 100644 src/graphql-aspnet/Web/HttpContextParser.cs create mode 100644 src/tests/graphql-aspnet-tests/Web/HttpContextParserTests.cs diff --git a/src/graphql-aspnet/Configuration/SchemaQueryHandlerConfiguration.cs b/src/graphql-aspnet/Configuration/SchemaQueryHandlerConfiguration.cs index 457765307..e01d5f401 100644 --- a/src/graphql-aspnet/Configuration/SchemaQueryHandlerConfiguration.cs +++ b/src/graphql-aspnet/Configuration/SchemaQueryHandlerConfiguration.cs @@ -33,9 +33,9 @@ public class SchemaQueryHandlerConfiguration /// /// Gets or sets a value indicating whether the default query processing controller - /// should be registered to the application. If disabled, the application will not register + /// should be registered to the application. When disabled, the application will not register /// its internal handler as a public end point; the application will need to handle - /// HTTP request routing manually (Default: false). + /// HTTP request routing manually (Default: false, "do include the default route"). /// /// true if the route should be disabled; otherwise, false. public bool DisableDefaultRoute { get; set; } = false; @@ -43,7 +43,7 @@ public class SchemaQueryHandlerConfiguration /// /// /// Gets or sets the route to which the internal controller registered for the schema will listen. - /// The route is automatically registered as a POST request. (Default: '/graphql'). + /// The route is automatically registered as a POST and GET request. (Default: '/graphql'). /// /// /// NOTE: If this application registers more than one this value must be unique diff --git a/src/graphql-aspnet/Defaults/DefaultGraphQLHttpProcessor{TSchema}.cs b/src/graphql-aspnet/Defaults/DefaultGraphQLHttpProcessor{TSchema}.cs index 4fc04f24e..e6041cec9 100644 --- a/src/graphql-aspnet/Defaults/DefaultGraphQLHttpProcessor{TSchema}.cs +++ b/src/graphql-aspnet/Defaults/DefaultGraphQLHttpProcessor{TSchema}.cs @@ -10,12 +10,8 @@ namespace GraphQL.AspNet.Defaults { using System; - using System.IO; using System.Net; - using System.Net.Http; using System.Security.Claims; - using System.Text; - using System.Text.Json; using System.Threading; using System.Threading.Tasks; using GraphQL.AspNet.Common; @@ -28,8 +24,8 @@ namespace GraphQL.AspNet.Defaults using GraphQL.AspNet.Interfaces.Web; using GraphQL.AspNet.Logging.Extensions; using GraphQL.AspNet.Security.Web; - using GraphQL.AspNet.Variables; using GraphQL.AspNet.Web; + using GraphQL.AspNet.Web.Exceptions; using Microsoft.AspNetCore.Http; /// @@ -45,12 +41,6 @@ public class DefaultGraphQLHttpProcessor : IGraphQLHttpProcessor protected const string ERROR_NO_QUERY_PROVIDED = "No query received on the request"; - /// - /// An error message constant, in english, providing the text to return to the caller when they use any HTTP action verb - /// other than post. - /// - protected const string ERROR_USE_POST = "GraphQL queries should be executed as a POST request"; - /// /// An error message constant, in english, providing the text to return to the caller when a 500 error is generated. /// @@ -62,12 +52,6 @@ public class DefaultGraphQLHttpProcessor : IGraphQLHttpProcessor protected const string ERROR_NO_REQUEST_CREATED = "GraphQL Operation Request is null. Unable to execute the query."; - /// - /// An error message format constant, in english, providing the text to return to teh caller - /// when an attempt to deserailize a json payload on a POST request fails. - /// - protected const string ERROR_SERIALIZATION_ISSUE_FORMAT = "Unable to deserialize the POST body as a JSON object: {0}"; - private readonly IGraphEventLogger _logger; private readonly TSchema _schema; private readonly IGraphQLRuntime _runtime; @@ -76,10 +60,10 @@ public class DefaultGraphQLHttpProcessor : IGraphQLHttpProcessor /// Initializes a new instance of the class. /// - /// The singleton instance of representing this processor works against. - /// The primary runtime instance in which GraphQL requests are processed for . - /// The result writer capable of converting a into a serialized payload - /// for the given . + /// The singleton instance of representing this processor works against. + /// The primary runtime instance in which GraphQL requests are processed for . + /// The result writer capable of converting a into a serialized payload + /// for the given . /// A logger instance where this object can write and record log entries. public DefaultGraphQLHttpProcessor( TSchema schema, @@ -90,6 +74,7 @@ public DefaultGraphQLHttpProcessor( _schema = Validation.ThrowIfNullOrReturn(schema, nameof(schema)); _runtime = Validation.ThrowIfNullOrReturn(runtime, nameof(runtime)); _writer = Validation.ThrowIfNullOrReturn(writer, nameof(writer)); + _logger = logger; } @@ -97,28 +82,15 @@ public DefaultGraphQLHttpProcessor( public virtual async Task Invoke(HttpContext context) { this.HttpContext = Validation.ThrowIfNullOrReturn(context, nameof(context)); - GraphQueryData queryData = null; - if (string.Equals(context.Request.Method, nameof(HttpMethod.Post), StringComparison.OrdinalIgnoreCase)) - { - try - { - queryData = await this.DecodeAsPostRequest(context); - } - catch (JsonException jex) - { - var message = string.Format(ERROR_SERIALIZATION_ISSUE_FORMAT, jex.Message); - await this.WriteStatusCodeResponse(HttpStatusCode.BadRequest, message, context.RequestAborted).ConfigureAwait(false); - return; - } - } - else if (string.Equals(context.Request.Method, nameof(HttpMethod.Get), StringComparison.OrdinalIgnoreCase)) + GraphQueryData queryData; + try { - queryData = this.DecodeAsGetRequest(context); + queryData = await this.ParseHttpContext(); } - else + catch (HttpContextParsingException ex) { - await this.WriteStatusCodeResponse(HttpStatusCode.BadRequest, ERROR_USE_POST, context.RequestAborted).ConfigureAwait(false); + await this.WriteStatusCodeResponse(ex.StatusCode, ex.Message, context.RequestAborted).ConfigureAwait(false); return; } @@ -126,83 +98,19 @@ public virtual async Task Invoke(HttpContext context) } /// - /// Attempts to extract the necessary keys from the query string of the - /// and create a object that can be processed by the GraphQL runtime. + /// When overriden in a child class, allows for the alteration of the method by which the various query + /// parameters are extracted from the for input to the graphql runtime. /// - /// The http context to deserialize. - /// GraphQueryData. - private GraphQueryData DecodeAsGetRequest(HttpContext context) + /// + /// Throw an to stop execution and quickly write + /// an error back to the requestor. + /// + /// A parsed query data object containing the input parameters for the + /// graphql runtime or null. + protected virtual async Task ParseHttpContext() { - var queryString = string.Empty; - var variables = string.Empty; - var operationName = string.Empty; - - if (context.Request.Query.ContainsKey(Constants.Web.QUERYSTRING_QUERY_KEY)) - queryString = context.Request.Query[Constants.Web.QUERYSTRING_QUERY_KEY]; - - if (context.Request.Query.ContainsKey(Constants.Web.QUERYSTRING_OPERATIONNAME_KEY)) - operationName = context.Request.Query[Constants.Web.QUERYSTRING_OPERATIONNAME_KEY]; - - if (context.Request.Query.ContainsKey(Constants.Web.QUERYSTRING_VARIABLES_KEY)) - variables = context.Request.Query[Constants.Web.QUERYSTRING_VARIABLES_KEY]; - - return new GraphQueryData() - { - Query = queryString, - OperationName = operationName, - Variables = InputVariableCollection.FromJsonDocument(variables), - }; - } - - /// - /// Attempts to deserialize the POST body of the - /// into a object that can be processed by the GraphQL runtime. - /// - /// The http context to deserialize. - /// GraphQueryData. - protected async Task DecodeAsPostRequest(HttpContext context) - { - // if a "query" parameter appears in the query string treat the request like a get - // request and parse parameters from the query string - // ----- - // See: https://graphql.org/learn/serving-over-http/#http-methods-headers-and-body - // ----- - if (context.Request.Query.ContainsKey(Constants.Web.QUERYSTRING_QUERY_KEY)) - return this.DecodeAsGetRequest(context); - - // if the content-type is set to graphql treat - // the whole POST body as the query string - // ----- - // See: https://graphql.org/learn/serving-over-http/#http-methods-headers-and-body - // ----- - if (context.Request.ContentType == Constants.Web.GRAPHQL_CONTENT_TYPE_HEADER_VALUE) - { - using var reader = new StreamReader(context.Request.Body, Encoding.UTF8, true, 1024, true); - var query = await reader.ReadToEndAsync(); - return new GraphQueryData() - { - Query = query, - }; - } - - // accepting a preparsed object (i.e. allowing .NET to decode it) - // can cause havoc with any variables collection - // ------ - // By default: - // netcoreapp2.2 and older would auto parse to JObject (Newtonsoft) - // netcoreapp3.0 and later will parse to JsonElement (System.Text.Json). - // - // and the developer may have some unexpected defaults configured as well - // ------ - // instead of supporting deserialization from both generic json object types - // we accept the raw text and parse the json document - // using System.Text.Json on all clients - var options = new JsonSerializerOptions(); - options.PropertyNameCaseInsensitive = true; - options.AllowTrailingCommas = true; - options.ReadCommentHandling = JsonCommentHandling.Skip; - - return await JsonSerializer.DeserializeAsync(context.Request.Body, options).ConfigureAwait(false); + var dataGenerator = new HttpContextParser(this.HttpContext); + return await dataGenerator.Parse(); } /// diff --git a/src/graphql-aspnet/GraphQueryData.cs b/src/graphql-aspnet/GraphQueryData.cs index d475bb187..fbe6597a4 100644 --- a/src/graphql-aspnet/GraphQueryData.cs +++ b/src/graphql-aspnet/GraphQueryData.cs @@ -12,7 +12,7 @@ namespace GraphQL.AspNet using GraphQL.AspNet.Variables; /// - /// An binding model representing an incoming, structured GraphQL query on a HTTP request. + /// A raw data package representing the various inputs to the graphql runtime. /// public class GraphQueryData { @@ -35,6 +35,13 @@ static GraphQueryData() }; } + /// + /// Initializes a new instance of the class. + /// + public GraphQueryData() + { + } + /// /// Gets or sets the name of the operation in the query document to execute. Must be included /// when the document defines more than one operation. diff --git a/src/graphql-aspnet/Web/Exceptions/HttpContextParsingException.cs b/src/graphql-aspnet/Web/Exceptions/HttpContextParsingException.cs new file mode 100644 index 000000000..165f998e6 --- /dev/null +++ b/src/graphql-aspnet/Web/Exceptions/HttpContextParsingException.cs @@ -0,0 +1,41 @@ +// ************************************************************* +// project: graphql-aspnet +// -- +// repo: https://github.com/graphql-aspnet +// docs: https://graphql-aspnet.github.io +// -- +// License: MIT +// ************************************************************* + +namespace GraphQL.AspNet.Web.Exceptions +{ + using System; + using System.Net; + using Microsoft.AspNetCore.Http; + + /// + /// An exception thrown when the runtime is uanble to successfully extract the + /// data from an to generate a data package + /// for the graphql runtime. + /// + public class HttpContextParsingException : Exception + { + /// + /// Initializes a new instance of the class. + /// + /// The status code taht should be set, indicating the reason for failure. + /// An end user friendly error message to include. This message will be + /// written directly to the http response. + public HttpContextParsingException(HttpStatusCode statusCode = HttpStatusCode.BadRequest, string errorMessage = "") + : base(errorMessage) + { + this.StatusCode = statusCode; + } + + /// + /// Gets the status code that should be set on the http response, indicating the reason for the failure. + /// + /// The status code. + public HttpStatusCode StatusCode { get; } + } +} \ No newline at end of file diff --git a/src/graphql-aspnet/Web/GraphQueryHandler{TSchema}.cs b/src/graphql-aspnet/Web/GraphQueryHandler{TSchema}.cs index 7a61581df..6609bc574 100644 --- a/src/graphql-aspnet/Web/GraphQueryHandler{TSchema}.cs +++ b/src/graphql-aspnet/Web/GraphQueryHandler{TSchema}.cs @@ -44,7 +44,7 @@ protected Task Invoke(HttpContext context) throw new InvalidOperationException( $"No {nameof(IGraphQLHttpProcessor)} of type " + $"{typeof(IGraphQLHttpProcessor).FriendlyName()} " + - "is registered with the DI container. The GraphQL runtime cannot invoke the schema."); + "is registered with the DI container. The GraphQL runtime cannot invoke the target schema."); } return processor.Invoke(context); diff --git a/src/graphql-aspnet/Web/HttpContextParser.cs b/src/graphql-aspnet/Web/HttpContextParser.cs new file mode 100644 index 000000000..298a32432 --- /dev/null +++ b/src/graphql-aspnet/Web/HttpContextParser.cs @@ -0,0 +1,194 @@ +// ************************************************************* +// project: graphql-aspnet +// -- +// repo: https://github.com/graphql-aspnet +// docs: https://graphql-aspnet.github.io +// -- +// License: MIT +// ************************************************************* + +namespace GraphQL.AspNet.Defaults +{ + using System; + using System.IO; + using System.Net.Http; + using System.Text; + using System.Text.Json; + using System.Threading.Tasks; + using GraphQL.AspNet.Common; + using GraphQL.AspNet.Variables; + using GraphQL.AspNet.Web.Exceptions; + using Microsoft.AspNetCore.Http; + + /// + /// An implementation of the business rules that extract the data received on an + /// to create a + /// object used by the graphql runtime. + /// + public class HttpContextParser + { + /// + /// An error message constant, in english, providing the text to return to the caller when they use any HTTP action verb + /// other than post. + /// + protected const string ERROR_USE_POST = "GraphQL queries should be executed as a POST request"; + + /// + /// An error message format constant, in english, providing the text to return to teh caller + /// when an attempt to deserailize a json payload on a POST request fails. + /// + protected const string ERROR_POST_SERIALIZATION_ISSUE_FORMAT = "Unable to deserialize the POST body as a JSON object: {0}"; + + /// + /// An error message format constant, in english, providing the text to return to the caller + /// when an attempt to deserialize the json payload that represents the variables collection, on the + /// query string, fails. + /// + protected static readonly string ERROR_VARIABLE_PARAMETER_SERIALIZATION_ISSUE_FORMAT = $"Unable to deserialize the '{Constants.Web.QUERYSTRING_VARIABLES_KEY}' query string parameter: {{0}}"; + + /// + /// Initializes a new instance of the class. + /// + /// The context to generate from. + public HttpContextParser(HttpContext context) + { + this.HttpContext = Validation.ThrowIfNullOrReturn(context, nameof(context)); + } + + /// + /// Generates a query data object usable by the graphql runtime. + /// + /// GraphQueryData. + public virtual async Task Parse() + { + GraphQueryData queryData = new GraphQueryData(); + + // ------------------------------ + // Step 0: GET or POST only + // --------------------------- + if (!this.IsPostRequest && !this.IsGetRequest) + throw new HttpContextParsingException(errorMessage: ERROR_USE_POST); + + // ------------------------------ + // Step 1: POST Body processing + // ------------------------------ + // First attempt to decode the post body when applicable + if (this.IsPostRequest) + { + try + { + queryData = await this.DecodePostBody(); + } + catch (JsonException ex) + { + var message = string.Format(ERROR_POST_SERIALIZATION_ISSUE_FORMAT, ex.Message); + throw new HttpContextParsingException(errorMessage: message); + } + } + + // ------------------------------ + // Step 2: Query String Updates + // ------------------------------ + // Apply overrides with the query string parameters if they exist + this.ApplyQueryStringOverrides(queryData); + + return queryData; + } + + /// + /// Attempts to extract the necessary keys from the query string of the http context + /// and update the supplied query data object with the values found. Query string values not found + /// are not applied to the supplied object. + /// + /// The query data object to update. + protected virtual void ApplyQueryStringOverrides(GraphQueryData queryData) + { + var httpQueryString = this.HttpContext.Request.Query; + if (httpQueryString.ContainsKey(Constants.Web.QUERYSTRING_QUERY_KEY)) + { + var query = httpQueryString[Constants.Web.QUERYSTRING_QUERY_KEY]; + if (!string.IsNullOrWhiteSpace(query)) + queryData.Query = query; + } + + if (httpQueryString.ContainsKey(Constants.Web.QUERYSTRING_OPERATIONNAME_KEY)) + { + var operationName = httpQueryString[Constants.Web.QUERYSTRING_OPERATIONNAME_KEY]; + if (!string.IsNullOrWhiteSpace(operationName)) + queryData.OperationName = operationName; + } + + if (httpQueryString.ContainsKey(Constants.Web.QUERYSTRING_VARIABLES_KEY)) + { + var variables = httpQueryString[Constants.Web.QUERYSTRING_VARIABLES_KEY]; + if (!string.IsNullOrWhiteSpace(variables)) + { + try + { + queryData.Variables = InputVariableCollection.FromJsonDocument(variables); + } + catch (JsonException ex) + { + var message = string.Format(ERROR_VARIABLE_PARAMETER_SERIALIZATION_ISSUE_FORMAT, ex.Message); + throw new HttpContextParsingException(errorMessage: message); + } + } + } + } + + /// + /// Attempts to deserialize the POST body of the httpcontext + /// into a object that can be processed by the GraphQL runtime. + /// + /// GraphQueryData. + protected virtual async Task DecodePostBody() + { + // if the content-type is set to graphql, treat + // the whole body as the query string + // ----- + // See: https://graphql.org/learn/serving-over-http/#http-methods-headers-and-body + // ----- + if (this.IsGraphQLBody) + { + using var reader = new StreamReader(this.HttpContext.Request.Body, Encoding.UTF8, true, 1024, true); + var query = await reader.ReadToEndAsync(); + return new GraphQueryData() + { + Query = query, + }; + } + + // attempt to decode the body as a json object + var options = new JsonSerializerOptions(); + options.PropertyNameCaseInsensitive = true; + options.AllowTrailingCommas = true; + options.ReadCommentHandling = JsonCommentHandling.Skip; + + return await JsonSerializer.DeserializeAsync(this.HttpContext.Request.Body, options, this.HttpContext.RequestAborted).ConfigureAwait(false); + } + + /// + /// Gets the context being parsed by this instance. + /// + /// The context. + protected HttpContext HttpContext { get; } + + /// + /// Gets a value indicating whether the watched http context is a GET request. + /// + /// A value indicating if the context is a GET request. + public bool IsGetRequest => string.Equals(this.HttpContext.Request.Method, nameof(HttpMethod.Get), StringComparison.OrdinalIgnoreCase); + + /// + /// Gets a value indicating whether the watched http context is a POST request. + /// + /// A value indicating if the context is a post request. + public bool IsPostRequest => string.Equals(this.HttpContext.Request.Method, nameof(HttpMethod.Post), StringComparison.OrdinalIgnoreCase); + + /// + /// Gets a value indicating whether the content-type of the http request is set to application/graphql. + /// + /// A value indicating if the http post body content type represents a graphql query. + public bool IsGraphQLBody => string.Equals(this.HttpContext.Request.ContentType, Constants.Web.GRAPHQL_CONTENT_TYPE_HEADER_VALUE, StringComparison.OrdinalIgnoreCase); + } +} \ No newline at end of file diff --git a/src/graphql-aspnet/graphql-aspnet.csproj b/src/graphql-aspnet/graphql-aspnet.csproj index 289af3aee..de45dfc3a 100644 --- a/src/graphql-aspnet/graphql-aspnet.csproj +++ b/src/graphql-aspnet/graphql-aspnet.csproj @@ -27,7 +27,7 @@ - + diff --git a/src/tests/graphql-aspnet-tests/Web/HttpContextParserTests.cs b/src/tests/graphql-aspnet-tests/Web/HttpContextParserTests.cs new file mode 100644 index 000000000..2fc06eb0e --- /dev/null +++ b/src/tests/graphql-aspnet-tests/Web/HttpContextParserTests.cs @@ -0,0 +1,312 @@ +// ************************************************************* +// project: graphql-aspnet +// -- +// repo: https://github.com/graphql-aspnet +// docs: https://graphql-aspnet.github.io +// -- +// License: MIT +// ************************************************************* + +namespace GraphQL.AspNet.Tests.Web +{ + using System.Collections.Generic; + using System.IO; + using System.Net; + using System.Text; + using System.Threading.Tasks; + using System.Web; + using GraphQL.AspNet.Defaults; + using GraphQL.AspNet.Variables; + using GraphQL.AspNet.Web.Exceptions; + using Microsoft.AspNetCore.Http; + using Microsoft.VisualStudio.TestPlatform.PlatformAbstractions.Interfaces; + using NUnit.Framework; + + [TestFixture] + public class HttpContextParserTests + { + public class HttpContextInputs + { + public bool IncludeQueryParameter { get; set; } + + public bool IncludeVariableParameter { get; set; } + + public bool IncludeOperationParameter { get; set; } + + public string QueryParameter { get; set; } + + public string VariableParameter { get; set; } + + public string OperationNameParameter { get; set; } + + public string Body { get; set; } + + public string Method { get; set; } + + public string ContentType { get; set; } + } + + public static List _testData; + + static HttpContextParserTests() + { + _testData = new List(); + + // All in post body + _testData.Add(new object[] + { + new HttpContextInputs() + { + Method = "POST", + ContentType = "application/json", + Body = "{ \"query\": \"{a{b c}}\", \"operationName\": \"a\", \"variables\": { \"q\": \"w\" } }", + }, + new GraphQueryData() + { + Query = "{a{b c}}", + OperationName = "a", + Variables = InputVariableCollection.FromJsonDocument("{\"q\": \"w\"}"), + }, + }); + + // All in query string as GET + _testData.Add(new object[] + { + new HttpContextInputs() + { + Method = "GET", + IncludeQueryParameter = true, + IncludeOperationParameter = true, + IncludeVariableParameter = true, + QueryParameter = "{a{b c}}", + OperationNameParameter = "a", + VariableParameter = HttpUtility.UrlEncode("{\"q\": \"w\"}"), + }, + + new GraphQueryData() + { + Query = "{a{b c}}", + OperationName = "a", + Variables = InputVariableCollection.FromJsonDocument("{\"q\": \"w\"}"), + }, + }); + + // post body with query string override + _testData.Add(new object[] + { + new HttpContextInputs() + { + IncludeQueryParameter = true, + QueryParameter = HttpUtility.UrlEncode("{a{b c}}"), + Method = "POST", + ContentType = "application/json", + Body = "{ \"query\": \"{b{a c}}\" }", + }, + new GraphQueryData() + { + Query = "{a{b c}}", + }, + }); + + // post body with operation name override + _testData.Add(new object[] + { + new HttpContextInputs() + { + IncludeOperationParameter = true, + OperationNameParameter = "a", + Method = "POST", + ContentType = "application/json", + Body = "{ \"query\": \"{b{a c}}\" , \"operationName\": \"b\"}", + }, + new GraphQueryData() + { + Query = "{b{a c}}", + OperationName = "a", + }, + }); + + // post body with variable set override + _testData.Add(new object[] + { + new HttpContextInputs() + { + Method = "POST", + ContentType = "application/json", + Body = "{ \"query\": \"{a{b c}}\", \"operationName\": \"a\", \"variables\": { \"q\": \"w\" } }", + + IncludeVariableParameter = true, + VariableParameter = HttpUtility.UrlEncode("{\"q\": \"j\"}"), + }, + new GraphQueryData() + { + Query = "{a{b c}}", + OperationName = "a", + Variables = InputVariableCollection.FromJsonDocument("{\"q\": \"j\"}"), + }, + }); + + // as graphql content type + _testData.Add(new object[] + { + new HttpContextInputs() + { + Method = "POST", + ContentType = Constants.Web.GRAPHQL_CONTENT_TYPE_HEADER_VALUE, + Body = "{a{b c}}", + }, + new GraphQueryData() + { + Query = "{a{b c}}", + }, + }); + + // as graphql content type with qury string override + _testData.Add(new object[] + { + new HttpContextInputs() + { + Method = "POST", + ContentType = Constants.Web.GRAPHQL_CONTENT_TYPE_HEADER_VALUE, + Body = "{a{b c}}", + IncludeQueryParameter = true, + QueryParameter = "{c{d e}}", + }, + new GraphQueryData() + { + Query = "{c{d e}}", + }, + }); + } + + [TestCaseSource(nameof(_testData))] + public async Task HttpParserTest(HttpContextInputs inputs, GraphQueryData expectedOutput) + { + var context = new DefaultHttpContext(); + + // setup the query string + var queryParams = new List(); + if (inputs.IncludeQueryParameter) + queryParams.Add($"{Constants.Web.QUERYSTRING_QUERY_KEY}={inputs.QueryParameter}"); + if (inputs.IncludeOperationParameter) + queryParams.Add($"{Constants.Web.QUERYSTRING_OPERATIONNAME_KEY}={inputs.OperationNameParameter}"); + if (inputs.IncludeVariableParameter) + queryParams.Add($"{Constants.Web.QUERYSTRING_VARIABLES_KEY}={inputs.VariableParameter}"); + + if (queryParams.Count > 0) + context.Request.QueryString = new QueryString("?" + string.Join("&", queryParams)); + + // setup request details + context.Request.Method = inputs.Method; + context.Request.ContentType = inputs.ContentType; + if (inputs.Body != null) + { + context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(inputs.Body)); + } + + var parser = new HttpContextParser(context); + var result = await parser.Parse(); + + Assert.AreEqual(expectedOutput.Query, result.Query); + Assert.AreEqual(expectedOutput.OperationName, result.OperationName); + + if (result.Variables == null) + { + Assert.AreEqual(expectedOutput.Variables, result.Variables); + return; + } + + Assert.AreEqual(expectedOutput.Variables.Count, result.Variables.Count); + foreach (var expectedVar in expectedOutput.Variables) + { + var found = result.Variables.TryGetVariable(expectedVar.Key, out var val); + Assert.IsTrue(found); + Assert.AreEqual(expectedVar.Value.GetType(), val.GetType()); + } + } + + [Test] + public async Task JsonDeserialziationError_PostBody_ThrowsHttpParseException() + { + var context = new DefaultHttpContext(); + + // invalid json + var bodyText = "\"query\": \"{a{b c}}\", \"operationName\": \"a\", \"variables\": { \"q\": \"w\" } }"; + + // setup request details + context.Request.Method = "POST"; + context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(bodyText)); + + var parser = new HttpContextParser(context); + + try + { + await parser.Parse(); + } + catch (HttpContextParsingException ex) + { + Assert.AreEqual(HttpStatusCode.BadRequest, ex.StatusCode); + return; + } + + Assert.Fail(); + } + + [Test] + public async Task JsonDeserialziationError_VariablesQueryString_ThrowsHttpParseException() + { + var context = new DefaultHttpContext(); + + // invalid json + var bodyText = "{ \"query\": \"{a{b c}}\" }"; + + // setup request details + context.Request.Method = "POST"; + context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(bodyText)); + + // not valid json in the variables parameter + context.Request.QueryString = new QueryString($"?{Constants.Web.QUERYSTRING_VARIABLES_KEY}={HttpUtility.UrlEncode("\"a\":\"b\"}")}"); + + var parser = new HttpContextParser(context); + + try + { + await parser.Parse(); + } + catch (HttpContextParsingException ex) + { + Assert.AreEqual(HttpStatusCode.BadRequest, ex.StatusCode); + return; + } + + Assert.Fail(); + } + + [Test] + public async Task NotPostOrGet_ThrowsHttpParseException() + { + var context = new DefaultHttpContext(); + + // invalid json + var bodyText = "{ \"query\": \"{a{b c}}\", \"operationName\": \"a\", \"variables\": { \"q\": \"w\" } }"; + + // setup request details + context.Request.Method = "DELETE"; + context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(bodyText)); + + var parser = new HttpContextParser(context); + + try + { + await parser.Parse(); + } + catch (HttpContextParsingException ex) + { + Assert.AreEqual(HttpStatusCode.BadRequest, ex.StatusCode); + return; + } + + Assert.Fail(); + } + } +} \ No newline at end of file