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/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/Constants.cs b/src/graphql-aspnet/Constants.cs index 60404e85e..ecb179195 100644 --- a/src/graphql-aspnet/Constants.cs +++ b/src/graphql-aspnet/Constants.cs @@ -568,6 +568,36 @@ public static class Pipelines public const string DIRECTIVE_PIPELINE = "Directive Execution Pipeline"; } + /// + /// A collection of constants related to the web processing of a query. + /// + 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"; + + /// + /// 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_VALUE = "application/graphql"; + } + /// /// 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..e6041cec9 100644 --- a/src/graphql-aspnet/Defaults/DefaultGraphQLHttpProcessor{TSchema}.cs +++ b/src/graphql-aspnet/Defaults/DefaultGraphQLHttpProcessor{TSchema}.cs @@ -11,9 +11,7 @@ namespace GraphQL.AspNet.Defaults { using System; using System.Net; - using System.Net.Http; using System.Security.Claims; - using System.Text.Json; using System.Threading; using System.Threading.Tasks; using GraphQL.AspNet.Common; @@ -27,6 +25,7 @@ namespace GraphQL.AspNet.Defaults using GraphQL.AspNet.Logging.Extensions; using GraphQL.AspNet.Security.Web; using GraphQL.AspNet.Web; + using GraphQL.AspNet.Web.Exceptions; using Microsoft.AspNetCore.Http; /// @@ -42,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. /// @@ -67,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, @@ -81,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; } @@ -88,28 +82,35 @@ 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; + try + { + queryData = await this.ParseHttpContext(); + } + 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; } - // accepting a parsed object causes 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). - // ------ - // 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) - var options = new JsonSerializerOptions(); - options.PropertyNameCaseInsensitive = true; - 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); + await this.SubmitGraphQLQuery(queryData, context.RequestAborted).ConfigureAwait(false); + } + + /// + /// 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. + /// + /// + /// 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 dataGenerator = new HttpContextParser(this.HttpContext); + return await dataGenerator.Parse(); } /// @@ -229,7 +230,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. @@ -238,6 +239,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/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/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/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/GetRequestTests.cs b/src/tests/graphql-aspnet-tests/Web/GetRequestTests.cs new file mode 100644 index 000000000..eab5c163d --- /dev/null +++ b/src/tests/graphql-aspnet-tests/Web/GetRequestTests.cs @@ -0,0 +1,276 @@ +// ************************************************************* +// 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.Text; + using System.Text.Json; + 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); + } + + [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_VALUE; + 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 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