Browse Source

Feature/graphql middleware (#650)

* #639 Create JSON schemas for all rule events

* Use graphql middleware.

* Guard it.

* Just some naming.

* Additional test for graphql to ensure compatibility.
pull/653/head
Sebastian Stehle 5 years ago
committed by GitHub
parent
commit
7529f9180e
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 58
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs
  2. 39
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/DefaultDocumentWriter.cs
  3. 60
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs
  4. 18
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs
  5. 5
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLService.cs
  6. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppMutationsGraphType.cs
  7. 9
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs
  8. 2
      backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj
  9. 17
      backend/src/Squidex.Web/GraphQL/DummySchema.cs
  10. 28
      backend/src/Squidex.Web/GraphQL/DynamicExecutor.cs
  11. 29
      backend/src/Squidex.Web/GraphQL/DynamicUserContextBuilder.cs
  12. 24
      backend/src/Squidex.Web/GraphQL/GraphQLMiddleware.cs
  13. 42
      backend/src/Squidex.Web/Pipeline/CachingFilter.cs
  14. 86
      backend/src/Squidex.Web/Pipeline/CachingKeysMiddleware.cs
  15. 2
      backend/src/Squidex.Web/Pipeline/CachingManager.cs
  16. 1
      backend/src/Squidex.Web/Squidex.Web.csproj
  17. 86
      backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs
  18. 31
      backend/src/Squidex/Areas/Api/Controllers/Contents/Models/GraphQLGetDto.cs
  19. 32
      backend/src/Squidex/Areas/Api/Controllers/Contents/Models/GraphQLPostDto.cs
  20. 3
      backend/src/Squidex/Config/Authentication/IdentityServices.cs
  21. 3
      backend/src/Squidex/Config/Domain/AssetServices.cs
  22. 3
      backend/src/Squidex/Config/Domain/CommandsServices.cs
  23. 3
      backend/src/Squidex/Config/Domain/ContentsServices.cs
  24. 3
      backend/src/Squidex/Config/Domain/HealthCheckServices.cs
  25. 42
      backend/src/Squidex/Config/Domain/InfrastructureServices.cs
  26. 6
      backend/src/Squidex/Config/Domain/LoggingServices.cs
  27. 3
      backend/src/Squidex/Config/Domain/MigrationServices.cs
  28. 3
      backend/src/Squidex/Config/Domain/NotificationsServices.cs
  29. 13
      backend/src/Squidex/Config/Domain/QueryServices.cs
  30. 3
      backend/src/Squidex/Config/Domain/RuleServices.cs
  31. 25
      backend/src/Squidex/Config/Domain/SerializationServices.cs
  32. 7
      backend/src/Squidex/Config/Web/WebExtensions.cs
  33. 37
      backend/src/Squidex/Config/Web/WebServices.cs
  34. 5
      backend/src/Squidex/Squidex.csproj
  35. 3
      backend/src/Squidex/Startup.cs
  36. 12
      backend/tests/Squidex.Domain.Apps.Core.Tests/TestHelpers/TestUtils.cs
  37. 5
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLIntrospectionTests.cs
  38. 93
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLMutationTests.cs
  39. 110
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs
  40. 50
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs
  41. 5
      backend/tests/Squidex.Infrastructure.Tests/Commands/LogCommandMiddlewareTests.cs
  42. 257
      backend/tests/Squidex.Web.Tests/Pipeline/CachingFilterTests.cs
  43. 328
      backend/tests/Squidex.Web.Tests/Pipeline/CachingKeysMiddlewareTests.cs
  44. 50
      backend/tools/TestSuite/TestSuite.ApiTests/ContentQueryTests.cs
  45. 4
      backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj
  46. 4
      backend/tools/TestSuite/TestSuite.LoadTests/TestSuite.LoadTests.csproj
  47. 2
      backend/tools/TestSuite/TestSuite.Shared/Fixtures/ContentReferencesFixture.cs
  48. 6
      backend/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj

58
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs

@ -6,8 +6,8 @@
// ========================================================================== // ==========================================================================
using System; using System;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using GraphQL;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using NodaTime; using NodaTime;
@ -19,6 +19,7 @@ using Squidex.Infrastructure;
using Squidex.Log; using Squidex.Log;
#pragma warning disable SA1313 // Parameter names should begin with lower-case letter #pragma warning disable SA1313 // Parameter names should begin with lower-case letter
#pragma warning disable RECS0082 // Parameter has the same name as a member and hides it
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
{ {
@ -30,7 +31,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
private readonly IServiceProvider serviceProvider; private readonly IServiceProvider serviceProvider;
private readonly GraphQLOptions options; private readonly GraphQLOptions options;
public sealed record CacheEntry(GraphQLModel Model, string Hash, Instant Created); private sealed record CacheEntry(GraphQLModel Model, string Hash, Instant Created);
public IServiceProvider Services
{
get { return serviceProvider; }
}
public CachingGraphQLService(IBackgroundCache cache, ISchemasHash schemasHash, IServiceProvider serviceProvider, IOptions<GraphQLOptions> options) public CachingGraphQLService(IBackgroundCache cache, ISchemasHash schemasHash, IServiceProvider serviceProvider, IOptions<GraphQLOptions> options)
{ {
@ -45,55 +51,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
this.options = options.Value; this.options = options.Value;
} }
public async Task<(bool HasError, object Response)> QueryAsync(Context context, params GraphQLQuery[] queries) public async Task<ExecutionResult> ExecuteAsync(ExecutionOptions options)
{
Guard.NotNull(context, nameof(context));
Guard.NotNull(queries, nameof(queries));
var model = await GetModelAsync(context.App);
var executionContext =
serviceProvider.GetRequiredService<GraphQLExecutionContext>()
.WithContext(context);
var result = await Task.WhenAll(queries.Select(q => QueryInternalAsync(model, executionContext, q)));
return (result.Any(x => x.HasError), result.Map(x => x.Response));
}
public async Task<(bool HasError, object Response)> QueryAsync(Context context, GraphQLQuery query)
{ {
Guard.NotNull(context, nameof(context)); var context = ((GraphQLExecutionContext)options.UserContext).Context;
Guard.NotNull(query, nameof(query));
var model = await GetModelAsync(context.App); var model = await GetModelAsync(context.App);
var executionContext = return await model.ExecuteAsync(options);
serviceProvider.GetRequiredService<GraphQLExecutionContext>()
.WithContext(context);
var result = await QueryInternalAsync(model, executionContext, query);
return result;
}
private static async Task<(bool HasError, object Response)> QueryInternalAsync(GraphQLModel model, GraphQLExecutionContext context, GraphQLQuery query)
{
if (string.IsNullOrWhiteSpace(query.Query))
{
return (false, new { data = new object() });
}
var (data, errors) = await model.ExecuteAsync(context, query);
if (errors?.Any() == true)
{
return (false, new { data, errors });
}
else
{
return (false, new { data });
}
} }
private async Task<GraphQLModel> GetModelAsync(IAppEntity app) private async Task<GraphQLModel> GetModelAsync(IAppEntity app)

39
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/DefaultDocumentWriter.cs

@ -0,0 +1,39 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using GraphQL;
using Microsoft.AspNetCore.WebUtilities;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
{
public sealed class DefaultDocumentWriter : IDocumentWriter
{
private readonly IJsonSerializer jsonSerializer;
public DefaultDocumentWriter(IJsonSerializer jsonSerializer)
{
Guard.NotNull(jsonSerializer, nameof(jsonSerializer));
this.jsonSerializer = jsonSerializer;
}
public async Task WriteAsync<T>(Stream stream, T value, CancellationToken cancellationToken = default)
{
await using (var buffer = new FileBufferingWriteStream())
{
jsonSerializer.Serialize(value, buffer, true);
await buffer.DrainBufferAsync(stream, cancellationToken);
}
}
}
}

60
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs

@ -8,7 +8,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using GraphQL;
using GraphQL.DataLoader; using GraphQL.DataLoader;
using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets;
@ -24,55 +23,32 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
{ {
private static readonly List<IEnrichedAssetEntity> EmptyAssets = new List<IEnrichedAssetEntity>(); private static readonly List<IEnrichedAssetEntity> EmptyAssets = new List<IEnrichedAssetEntity>();
private static readonly List<IEnrichedContentEntity> EmptyContents = new List<IEnrichedContentEntity>(); private static readonly List<IEnrichedContentEntity> EmptyContents = new List<IEnrichedContentEntity>();
private readonly IDataLoaderContextAccessor dataLoaderContextAccessor; private readonly IDataLoaderContextAccessor dataLoaders;
private readonly DataLoaderDocumentListener dataLoaderDocumentListener;
private readonly IUrlGenerator urlGenerator;
private readonly ISemanticLog log;
private readonly ICommandBus commandBus;
private Context context;
public IUrlGenerator UrlGenerator
{
get { return urlGenerator; }
}
public ICommandBus CommandBus public IUrlGenerator UrlGenerator { get; }
{
get { return commandBus; }
}
public ISemanticLog Log public ICommandBus CommandBus { get; }
{
get { return log; }
}
public override Context Context public ISemanticLog Log { get; }
{
get { return context; } public override Context Context { get; }
}
public GraphQLExecutionContext(IAssetQueryService assetQuery, IContentQueryService contentQuery, public GraphQLExecutionContext(IAssetQueryService assetQuery, IContentQueryService contentQuery,
IDataLoaderContextAccessor dataLoaderContextAccessor, DataLoaderDocumentListener dataLoaderDocumentListener, ICommandBus commandBus, IUrlGenerator urlGenerator, ISemanticLog log) Context context,
IDataLoaderContextAccessor dataLoaders, ICommandBus commandBus, IUrlGenerator urlGenerator, ISemanticLog log)
: base(assetQuery, contentQuery) : base(assetQuery, contentQuery)
{ {
this.commandBus = commandBus; this.dataLoaders = dataLoaders;
this.dataLoaderContextAccessor = dataLoaderContextAccessor;
this.dataLoaderDocumentListener = dataLoaderDocumentListener;
this.urlGenerator = urlGenerator;
this.log = log;
}
public GraphQLExecutionContext WithContext(Context newContext) CommandBus = commandBus;
{
context = newContext.Clone(b => b.WithoutCleanup().WithoutContentEnrichment());
return this; UrlGenerator = urlGenerator;
}
public void Setup(ExecutionOptions execution) Context = context.Clone(b => b
{ .WithoutCleanup()
execution.Listeners.Add(dataLoaderDocumentListener); .WithoutContentEnrichment());
execution.UserContext = this;
Log = log;
} }
public async Task<IEnrichedAssetEntity?> FindAssetAsync(DomainId id) public async Task<IEnrichedAssetEntity?> FindAssetAsync(DomainId id)
@ -119,7 +95,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
private IDataLoader<DomainId, IEnrichedAssetEntity> GetAssetsLoader() private IDataLoader<DomainId, IEnrichedAssetEntity> GetAssetsLoader()
{ {
return dataLoaderContextAccessor.Context.GetOrAddBatchLoader<DomainId, IEnrichedAssetEntity>(nameof(GetAssetsLoader), return dataLoaders.Context.GetOrAddBatchLoader<DomainId, IEnrichedAssetEntity>(nameof(GetAssetsLoader),
async batch => async batch =>
{ {
var result = await GetReferencedAssetsAsync(new List<DomainId>(batch)); var result = await GetReferencedAssetsAsync(new List<DomainId>(batch));
@ -130,7 +106,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
private IDataLoader<DomainId, IEnrichedContentEntity> GetContentsLoader() private IDataLoader<DomainId, IEnrichedContentEntity> GetContentsLoader()
{ {
return dataLoaderContextAccessor.Context.GetOrAddBatchLoader<DomainId, IEnrichedContentEntity>(nameof(GetContentsLoader), return dataLoaders.Context.GetOrAddBatchLoader<DomainId, IEnrichedContentEntity>(nameof(GetContentsLoader),
async batch => async batch =>
{ {
var result = await GetReferencedContentsAsync(new List<DomainId>(batch)); var result = await GetReferencedContentsAsync(new List<DomainId>(batch));

18
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs

@ -12,7 +12,6 @@ using GraphQL;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types; using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types;
using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Squidex.Log; using Squidex.Log;
using GraphQLSchema = GraphQL.Types.Schema; using GraphQLSchema = GraphQL.Types.Schema;
@ -31,18 +30,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
schema = new Builder(app, typeFactory).BuildSchema(schemas); schema = new Builder(app, typeFactory).BuildSchema(schemas);
} }
public async Task<(object Data, object[]? Errors)> ExecuteAsync(GraphQLExecutionContext context, GraphQLQuery query) public async Task<ExecutionResult> ExecuteAsync(ExecutionOptions options)
{ {
Guard.NotNull(context, nameof(context)); options.Schema = schema;
var result = await Executor.ExecuteAsync(execution => var result = await Executor.ExecuteAsync(options);
{
context.Setup(execution);
execution.Schema = schema;
execution.Inputs = query.Inputs;
execution.Query = query.Query;
});
if (result.Errors != null && result.Errors.Any()) if (result.Errors != null && result.Errors.Any())
{ {
@ -58,9 +50,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
})); }));
} }
var errors = result.Errors?.Select(x => (object)new { x.Message, x.Locations }).ToArray(); return result;
return (result.Data, errors);
} }
} }
} }

5
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLService.cs

@ -6,13 +6,12 @@
// ========================================================================== // ==========================================================================
using System.Threading.Tasks; using System.Threading.Tasks;
using GraphQL;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
{ {
public interface IGraphQLService public interface IGraphQLService
{ {
Task<(bool HasError, object Response)> QueryAsync(Context context, params GraphQLQuery[] queries); Task<ExecutionResult> ExecuteAsync(ExecutionOptions options);
Task<(bool HasError, object Response)> QueryAsync(Context context, GraphQLQuery query);
} }
} }

2
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppMutationsGraphType.cs

@ -19,7 +19,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
{ {
foreach (var schemaInfo in schemas.Where(x => x.Fields.Count > 0)) foreach (var schemaInfo in schemas.Where(x => x.Fields.Count > 0))
{ {
var contentType = builder.GetContentType(schemaInfo); var contentType = new NonNullGraphType(builder.GetContentType(schemaInfo));
var inputType = new DataInputGraphType(builder, schemaInfo); var inputType = new DataInputGraphType(builder, schemaInfo);

9
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs

@ -207,11 +207,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents
public static readonly IFieldResolver Resolver = ResolveAsync(Permissions.AppContentsCreate, c => public static readonly IFieldResolver Resolver = ResolveAsync(Permissions.AppContentsCreate, c =>
{ {
var contentPublish = c.GetArgument<bool>("publish"); var publish = c.GetArgument<bool>("publish");
var contentData = GetContentData(c); var contentData = GetContentData(c);
var contentId = c.GetArgument<string?>("id"); var contentId = c.GetArgument<string?>("id");
var command = new CreateContent { Data = contentData, Publish = contentPublish }; var command = new CreateContent { Data = contentData, Publish = publish };
if (!string.IsNullOrWhiteSpace(contentId)) if (!string.IsNullOrWhiteSpace(contentId))
{ {
@ -263,13 +263,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents
public static readonly IFieldResolver Resolver = ResolveAsync(Permissions.AppContentsUpsert, c => public static readonly IFieldResolver Resolver = ResolveAsync(Permissions.AppContentsUpsert, c =>
{ {
var contentPublish = c.GetArgument<bool>("publish"); var publish = c.GetArgument<bool>("publish");
var contentData = GetContentData(c); var contentData = GetContentData(c);
var contentId = c.GetArgument<string>("id"); var contentId = c.GetArgument<string>("id");
var id = DomainId.Create(contentId); var id = DomainId.Create(contentId);
return new UpsertContent { ContentId = id, Data = contentData, Publish = contentPublish }; return new UpsertContent { ContentId = id, Data = contentData, Publish = publish };
}); });
} }

2
backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj

@ -27,7 +27,7 @@
<PackageReference Include="Squidex.Assets" Version="1.3.0" /> <PackageReference Include="Squidex.Assets" Version="1.3.0" />
<PackageReference Include="Squidex.Caching" Version="1.8.0" /> <PackageReference Include="Squidex.Caching" Version="1.8.0" />
<PackageReference Include="Squidex.Hosting.Abstractions" Version="1.8.0" /> <PackageReference Include="Squidex.Hosting.Abstractions" Version="1.8.0" />
<PackageReference Include="Squidex.Log" Version="1.1.0" /> <PackageReference Include="Squidex.Log" Version="1.2.0" />
<PackageReference Include="Squidex.Text" Version="1.5.0" /> <PackageReference Include="Squidex.Text" Version="1.5.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="5.0.0" /> <PackageReference Include="System.Collections.Immutable" Version="5.0.0" />

17
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLQuery.cs → backend/src/Squidex.Web/GraphQL/DummySchema.cs

@ -1,20 +1,19 @@
// ========================================================================== // ==========================================================================
// Squidex Headless CMS // Squidex Headless CMS
// ========================================================================== // ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt) // Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using GraphQL; using GraphQL.Types;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL namespace Squidex.Web.GraphQL
{ {
public sealed class GraphQLQuery public sealed class DummySchema : Schema
{ {
public string OperationName { get; set; } public DummySchema()
{
public string Query { get; set; } Query = new ObjectGraphType();
}
public Inputs? Inputs { get; set; }
} }
} }

28
backend/src/Squidex.Web/GraphQL/DynamicExecutor.cs

@ -0,0 +1,28 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using GraphQL;
using Squidex.Domain.Apps.Entities.Contents.GraphQL;
namespace Squidex.Web.GraphQL
{
public sealed class DynamicExecutor : IDocumentExecuter
{
private readonly IGraphQLService graphQLService;
public DynamicExecutor(IGraphQLService graphQLService)
{
this.graphQLService = graphQLService;
}
public Task<ExecutionResult> ExecuteAsync(ExecutionOptions options)
{
return graphQLService.ExecuteAsync(options);
}
}
}

29
backend/src/Squidex.Web/GraphQL/DynamicUserContextBuilder.cs

@ -0,0 +1,29 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Threading.Tasks;
using GraphQL.Server.Transports.AspNetCore;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Contents.GraphQL;
namespace Squidex.Web.GraphQL
{
public sealed class DynamicUserContextBuilder : IUserContextBuilder
{
private readonly ObjectFactory factory = ActivatorUtilities.CreateFactory(typeof(GraphQLExecutionContext), new[] { typeof(Context) });
public Task<IDictionary<string, object>> BuildUserContext(HttpContext httpContext)
{
var executionContext = (GraphQLExecutionContext)factory(httpContext.RequestServices, new object[] { httpContext.Context() });
return Task.FromResult<IDictionary<string, object>>(executionContext);
}
}
}

24
backend/src/Squidex.Web/GraphQL/GraphQLMiddleware.cs

@ -0,0 +1,24 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using GraphQL.Server.Transports.AspNetCore;
using GraphQL.Server.Transports.AspNetCore.Common;
using Microsoft.AspNetCore.Http;
namespace Squidex.Web.GraphQL
{
public sealed class GraphQLMiddleware : GraphQLHttpMiddleware<DummySchema>
{
private static readonly RequestDelegate Noop = _ => Task.CompletedTask;
public GraphQLMiddleware(IGraphQLRequestDeserializer deserializer)
: base(Noop, default, deserializer)
{
}
}
}

42
backend/src/Squidex.Web/Pipeline/CachingFilter.cs

@ -10,24 +10,19 @@ using System.Threading.Tasks;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers; using Microsoft.Net.Http.Headers;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Security;
namespace Squidex.Web.Pipeline namespace Squidex.Web.Pipeline
{ {
public sealed class CachingFilter : IAsyncActionFilter public sealed class CachingFilter : IAsyncActionFilter
{ {
private readonly CachingOptions cachingOptions;
private readonly CachingManager cachingManager; private readonly CachingManager cachingManager;
public CachingFilter(CachingManager cachingManager, IOptions<CachingOptions> cachingOptions) public CachingFilter(CachingManager cachingManager)
{ {
Guard.NotNull(cachingManager, nameof(cachingManager)); Guard.NotNull(cachingManager, nameof(cachingManager));
Guard.NotNull(cachingOptions, nameof(cachingOptions));
this.cachingOptions = cachingOptions.Value;
this.cachingManager = cachingManager; this.cachingManager = cachingManager;
} }
@ -35,26 +30,15 @@ namespace Squidex.Web.Pipeline
{ {
cachingManager.Start(context.HttpContext); cachingManager.Start(context.HttpContext);
AppendAuthHeaders(context.HttpContext);
var resultContext = await next(); var resultContext = await next();
if (resultContext.HttpContext.Response.Headers.TryGetString(HeaderNames.ETag, out var etag)) if (resultContext.HttpContext.Response.Headers.TryGetString(HeaderNames.ETag, out var etag))
{ {
if (!cachingOptions.StrongETag && IsWeakEtag(etag))
{
etag = ToWeakEtag(etag);
resultContext.HttpContext.Response.Headers[HeaderNames.ETag] = etag;
}
if (IsCacheable(resultContext.HttpContext, etag)) if (IsCacheable(resultContext.HttpContext, etag))
{ {
resultContext.Result = new StatusCodeResult(304); resultContext.Result = new StatusCodeResult(304);
} }
} }
cachingManager.Finish(resultContext.HttpContext);
} }
private static bool IsCacheable(HttpContext httpContext, string etag) private static bool IsCacheable(HttpContext httpContext, string etag)
@ -64,29 +48,5 @@ namespace Squidex.Web.Pipeline
httpContext.Request.Headers.TryGetString(HeaderNames.IfNoneMatch, out var noneMatch) && httpContext.Request.Headers.TryGetString(HeaderNames.IfNoneMatch, out var noneMatch) &&
string.Equals(etag, noneMatch, StringComparison.Ordinal); string.Equals(etag, noneMatch, StringComparison.Ordinal);
} }
private void AppendAuthHeaders(HttpContext httpContext)
{
cachingManager.AddHeader("Auth-State");
if (!string.IsNullOrWhiteSpace(httpContext.User.OpenIdSubject()))
{
cachingManager.AddHeader(HeaderNames.Authorization);
}
else if (!string.IsNullOrWhiteSpace(httpContext.User.OpenIdClientId()))
{
cachingManager.AddHeader("Auth-ClientId");
}
}
private static string ToWeakEtag(string? etag)
{
return $"W/{etag}";
}
private static bool IsWeakEtag(string etag)
{
return !etag.StartsWith("W/", StringComparison.OrdinalIgnoreCase);
}
} }
} }

86
backend/src/Squidex.Web/Pipeline/CachingKeysMiddleware.cs

@ -0,0 +1,86 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Security;
namespace Squidex.Web.Pipeline
{
public sealed class CachingKeysMiddleware
{
private readonly CachingOptions cachingOptions;
private readonly CachingManager cachingManager;
private readonly RequestDelegate next;
public CachingKeysMiddleware(CachingManager cachingManager, IOptions<CachingOptions> cachingOptions, RequestDelegate next)
{
Guard.NotNull(cachingManager, nameof(cachingManager));
Guard.NotNull(cachingOptions, nameof(cachingOptions));
Guard.NotNull(next, nameof(next));
this.cachingOptions = cachingOptions.Value;
this.cachingManager = cachingManager;
this.next = next;
}
public async Task InvokeAsync(HttpContext context)
{
cachingManager.Start(context);
AppendAuthHeaders(context);
context.Response.OnStarting(x =>
{
var httpContext = (HttpContext)x;
if (httpContext.Response.Headers.TryGetString(HeaderNames.ETag, out var etag))
{
if (!cachingOptions.StrongETag && IsWeakEtag(etag))
{
httpContext.Response.Headers[HeaderNames.ETag] = ToWeakEtag(etag);
}
}
cachingManager.Finish(httpContext);
return Task.CompletedTask;
}, context);
await next(context);
}
private void AppendAuthHeaders(HttpContext httpContext)
{
cachingManager.AddHeader("Auth-State");
if (!string.IsNullOrWhiteSpace(httpContext.User.OpenIdSubject()))
{
cachingManager.AddHeader(HeaderNames.Authorization);
}
else if (!string.IsNullOrWhiteSpace(httpContext.User.OpenIdClientId()))
{
cachingManager.AddHeader("Auth-ClientId");
}
}
private static string ToWeakEtag(string? etag)
{
return $"W/{etag}";
}
private static bool IsWeakEtag(string etag)
{
return !etag.StartsWith("W/", StringComparison.OrdinalIgnoreCase);
}
}
}

2
backend/src/Squidex.Web/Pipeline/CachingManager.cs

@ -231,7 +231,7 @@ namespace Squidex.Web.Pipeline
{ {
Guard.NotNull(httpContext, nameof(httpContext)); Guard.NotNull(httpContext, nameof(httpContext));
var cacheContext = httpContextAccessor.HttpContext?.Features.Get<CacheContext>(); var cacheContext = httpContext.Features.Get<CacheContext>();
cacheContext?.Finish(httpContext.Response, stringBuilderPool); cacheContext?.Finish(httpContext.Response, stringBuilderPool);
} }

1
backend/src/Squidex.Web/Squidex.Web.csproj

@ -16,6 +16,7 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="GraphQL.Server.Transports.AspNetCore" Version="4.4.1" />
<PackageReference Include="Lazy.Fody" Version="1.9.0" PrivateAssets="all" /> <PackageReference Include="Lazy.Fody" Version="1.9.0" PrivateAssets="all" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />

86
backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs

@ -14,11 +14,11 @@ using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.Commands; using Squidex.Domain.Apps.Entities.Contents.Commands;
using Squidex.Domain.Apps.Entities.Contents.GraphQL;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Shared; using Squidex.Shared;
using Squidex.Web; using Squidex.Web;
using Squidex.Web.GraphQL;
namespace Squidex.Areas.Api.Controllers.Contents namespace Squidex.Areas.Api.Controllers.Contents
{ {
@ -26,25 +26,24 @@ namespace Squidex.Areas.Api.Controllers.Contents
{ {
private readonly IContentQueryService contentQuery; private readonly IContentQueryService contentQuery;
private readonly IContentWorkflow contentWorkflow; private readonly IContentWorkflow contentWorkflow;
private readonly IGraphQLService graphQl; private readonly GraphQLMiddleware graphQLMiddleware;
public ContentsController(ICommandBus commandBus, public ContentsController(ICommandBus commandBus,
IContentQueryService contentQuery, IContentQueryService contentQuery,
IContentWorkflow contentWorkflow, IContentWorkflow contentWorkflow,
IGraphQLService graphQl) GraphQLMiddleware graphQLMiddleware)
: base(commandBus) : base(commandBus)
{ {
this.contentQuery = contentQuery; this.contentQuery = contentQuery;
this.contentWorkflow = contentWorkflow; this.contentWorkflow = contentWorkflow;
this.graphQl = graphQl; this.graphQLMiddleware = graphQLMiddleware;
} }
/// <summary> /// <summary>
/// GraphQL endpoint. /// GraphQL endpoint.
/// </summary> /// </summary>
/// <param name="app">The name of the app.</param> /// <param name="app">The name of the app.</param>
/// <param name="queries">The graphql query.</param>
/// <returns> /// <returns>
/// 200 => Contents returned or mutated. /// 200 => Contents returned or mutated.
/// 404 => App not found. /// 404 => App not found.
@ -53,87 +52,14 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// You can read the generated documentation for your app at /api/content/{appName}/docs. /// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks> /// </remarks>
[HttpGet] [HttpGet]
[Route("content/{app}/graphql/")]
[ApiPermissionOrAnonymous]
[ApiCosts(2)]
public async Task<IActionResult> GetGraphQL(string app, [FromQuery] GraphQLGetDto? queries = null)
{
var request = queries?.ToQuery() ?? new GraphQLQuery();
var (hasError, response) = await graphQl.QueryAsync(Context, request);
if (hasError)
{
return BadRequest(response);
}
else
{
return Ok(response);
}
}
/// <summary>
/// GraphQL endpoint.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="query">The graphql query.</param>
/// <returns>
/// 200 => Contents returned or mutated.
/// 404 => App not found.
/// </returns>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
[HttpPost] [HttpPost]
[Route("content/{app}/graphql/")] [Route("content/{app}/graphql/")]
[ApiPermissionOrAnonymous]
[ApiCosts(2)]
public async Task<IActionResult> PostGraphQL(string app, [FromBody] GraphQLPostDto query)
{
var request = query?.ToQuery() ?? new GraphQLQuery();
var (hasError, response) = await graphQl.QueryAsync(Context, request);
if (hasError)
{
return BadRequest(response);
}
else
{
return Ok(response);
}
}
/// <summary>
/// GraphQL endpoint (Batch).
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="batch">The graphql queries.</param>
/// <returns>
/// 200 => Contents returned or mutated.
/// 404 => App not found.
/// </returns>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
[HttpPost]
[Route("content/{app}/graphql/batch")] [Route("content/{app}/graphql/batch")]
[ApiPermissionOrAnonymous] [ApiPermissionOrAnonymous]
[ApiCosts(2)] [ApiCosts(2)]
public async Task<IActionResult> PostGraphQLBatch(string app, [FromBody] GraphQLPostDto[] batch) public Task GetGraphQL(string app)
{ {
var request = batch.Select(x => x.ToQuery()).ToArray(); return graphQLMiddleware.InvokeAsync(HttpContext);
var (hasError, response) = await graphQl.QueryAsync(Context, request);
if (hasError)
{
return BadRequest(response);
}
else
{
return Ok(response);
}
} }
/// <summary> /// <summary>

31
backend/src/Squidex/Areas/Api/Controllers/Contents/Models/GraphQLGetDto.cs

@ -1,31 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using GraphQL.NewtonsoftJson;
using Squidex.Domain.Apps.Entities.Contents.GraphQL;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Areas.Api.Controllers.Contents.Models
{
public class GraphQLGetDto
{
public string OperationName { get; set; }
public string Query { get; set; }
public string Variables { get; set; }
public GraphQLQuery ToQuery()
{
var query = SimpleMapper.Map(this, new GraphQLQuery());
query.Inputs = Variables?.ToInputs();
return query;
}
}
}

32
backend/src/Squidex/Areas/Api/Controllers/Contents/Models/GraphQLPostDto.cs

@ -1,32 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using GraphQL.NewtonsoftJson;
using Newtonsoft.Json.Linq;
using Squidex.Domain.Apps.Entities.Contents.GraphQL;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Areas.Api.Controllers.Contents.Models
{
public class GraphQLPostDto
{
public string OperationName { get; set; }
public string Query { get; set; }
public JObject Variables { get; set; }
public GraphQLQuery ToQuery()
{
var query = SimpleMapper.Map(this, new GraphQLQuery());
query.Inputs = Variables?.ToInputs();
return query;
}
}
}

3
backend/src/Squidex/Config/Authentication/IdentityServices.cs

@ -16,7 +16,8 @@ namespace Squidex.Config.Authentication
{ {
public static void AddSquidexIdentity(this IServiceCollection services, IConfiguration config) public static void AddSquidexIdentity(this IServiceCollection services, IConfiguration config)
{ {
services.Configure<MyIdentityOptions>(config, "identity"); services.Configure<MyIdentityOptions>(config,
"identity");
services.AddSingletonAs<DefaultUserResolver>() services.AddSingletonAs<DefaultUserResolver>()
.AsOptional<IUserResolver>(); .AsOptional<IUserResolver>();

3
backend/src/Squidex/Config/Domain/AssetServices.cs

@ -29,7 +29,8 @@ namespace Squidex.Config.Domain
{ {
public static void AddSquidexAssets(this IServiceCollection services, IConfiguration config) public static void AddSquidexAssets(this IServiceCollection services, IConfiguration config)
{ {
services.Configure<AssetOptions>(config, "assets"); services.Configure<AssetOptions>(config,
"assets");
if (config.GetValue<bool>("assets:deleteRecursive")) if (config.GetValue<bool>("assets:deleteRecursive"))
{ {

3
backend/src/Squidex/Config/Domain/CommandsServices.cs

@ -32,7 +32,8 @@ namespace Squidex.Config.Domain
{ {
public static void AddSquidexCommands(this IServiceCollection services, IConfiguration config) public static void AddSquidexCommands(this IServiceCollection services, IConfiguration config)
{ {
services.Configure<ReadonlyOptions>(config, "mode"); services.Configure<ReadonlyOptions>(config,
"mode");
services.AddSingletonAs<InMemoryCommandBus>() services.AddSingletonAs<InMemoryCommandBus>()
.As<ICommandBus>(); .As<ICommandBus>();

3
backend/src/Squidex/Config/Domain/ContentsServices.cs

@ -27,7 +27,8 @@ namespace Squidex.Config.Domain
{ {
public static void AddSquidexContents(this IServiceCollection services, IConfiguration config) public static void AddSquidexContents(this IServiceCollection services, IConfiguration config)
{ {
services.Configure<ContentOptions>(config, "contents"); services.Configure<ContentOptions>(config,
"contents");
services.AddSingletonAs(c => new Lazy<IContentQueryService>(c.GetRequiredService<IContentQueryService>)) services.AddSingletonAs(c => new Lazy<IContentQueryService>(c.GetRequiredService<IContentQueryService>))
.AsSelf(); .AsSelf();

3
backend/src/Squidex/Config/Domain/HealthCheckServices.cs

@ -17,7 +17,8 @@ namespace Squidex.Config.Domain
{ {
public static void AddSquidexHealthChecks(this IServiceCollection services, IConfiguration config) public static void AddSquidexHealthChecks(this IServiceCollection services, IConfiguration config)
{ {
services.Configure<GCHealthCheckOptions>(config, "healthz:gc"); services.Configure<GCHealthCheckOptions>(config,
"healthz:gc");
services.AddHealthChecks() services.AddHealthChecks()
.AddCheck<GCHealthCheck>("GC", tags: new[] { "node" }) .AddCheck<GCHealthCheck>("GC", tags: new[] { "node" })

42
backend/src/Squidex/Config/Domain/InfrastructureServices.cs

@ -29,8 +29,10 @@ using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.EventSourcing.Grains; using Squidex.Infrastructure.EventSourcing.Grains;
using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.Translations;
using Squidex.Infrastructure.UsageTracking; using Squidex.Infrastructure.UsageTracking;
using Squidex.Pipeline.Robots; using Squidex.Pipeline.Robots;
using Squidex.Shared;
using Squidex.Text.Translations; using Squidex.Text.Translations;
using Squidex.Text.Translations.GoogleCloud; using Squidex.Text.Translations.GoogleCloud;
using Squidex.Web; using Squidex.Web;
@ -42,9 +44,11 @@ namespace Squidex.Config.Domain
{ {
public static void AddSquidexInfrastructure(this IServiceCollection services, IConfiguration config) public static void AddSquidexInfrastructure(this IServiceCollection services, IConfiguration config)
{ {
services.Configure<ExposedConfiguration>(config, "exposedConfiguration"); services.Configure<ExposedConfiguration>(config,
"exposedConfiguration");
services.Configure<ReplicatedCacheOptions>(config, "caching:replicated"); services.Configure<ReplicatedCacheOptions>(config,
"caching:replicated");
services.AddReplicatedCache(); services.AddReplicatedCache();
services.AddAsyncLocalCache(); services.AddAsyncLocalCache();
@ -100,7 +104,8 @@ namespace Squidex.Config.Domain
public static void AddSquidexUsageTracking(this IServiceCollection services, IConfiguration config) public static void AddSquidexUsageTracking(this IServiceCollection services, IConfiguration config)
{ {
services.Configure<UsageOptions>(config, "usage"); services.Configure<UsageOptions>(config,
"usage");
services.AddSingletonAs(c => new CachingUsageTracker( services.AddSingletonAs(c => new CachingUsageTracker(
c.GetRequiredService<BackgroundUsageTracker>(), c.GetRequiredService<BackgroundUsageTracker>(),
@ -119,11 +124,14 @@ namespace Squidex.Config.Domain
public static void AddSquidexTranslation(this IServiceCollection services, IConfiguration config) public static void AddSquidexTranslation(this IServiceCollection services, IConfiguration config)
{ {
services.Configure<GoogleCloudTranslationOptions>(config, "translations:googleCloud"); services.Configure<GoogleCloudTranslationOptions>(config,
"translations:googleCloud");
services.Configure<DeepLOptions>(config, "translations:deepL"); services.Configure<DeepLOptions>(config,
"translations:deepL");
services.Configure<LanguagesOptions>(config, "languages"); services.Configure<LanguagesOptions>(config,
"languages");
services.AddSingletonAs<LanguagesInitializer>() services.AddSingletonAs<LanguagesInitializer>()
.AsSelf(); .AsSelf();
@ -138,15 +146,29 @@ namespace Squidex.Config.Domain
.As<ITranslator>(); .As<ITranslator>();
} }
public static void AddSquidexLocalization(this IServiceCollection services)
{
var translator = new ResourcesLocalizer(Texts.ResourceManager);
T.Setup(translator);
services.AddSingletonAs(c => translator)
.As<ILocalizer>();
}
public static void AddSquidexControllerServices(this IServiceCollection services, IConfiguration config) public static void AddSquidexControllerServices(this IServiceCollection services, IConfiguration config)
{ {
services.Configure<RobotsTxtOptions>(config, "robots"); services.Configure<RobotsTxtOptions>(config,
"robots");
services.Configure<CachingOptions>(config, "caching"); services.Configure<CachingOptions>(config,
"caching");
services.Configure<MyUIOptions>(config, "ui"); services.Configure<MyUIOptions>(config,
"ui");
services.Configure<MyNewsOptions>(config, "news"); services.Configure<MyNewsOptions>(config,
"news");
services.AddSingletonAs<FeaturesService>() services.AddSingletonAs<FeaturesService>()
.AsSelf(); .AsSelf();

6
backend/src/Squidex/Config/Domain/LoggingServices.cs

@ -35,9 +35,11 @@ namespace Squidex.Config.Domain
private static void AddServices(this IServiceCollection services, IConfiguration config) private static void AddServices(this IServiceCollection services, IConfiguration config)
{ {
services.Configure<RequestLogOptions>(config, "logging"); services.Configure<RequestLogOptions>(config,
"logging");
services.Configure<RequestLogStoreOptions>(config, "logging"); services.Configure<RequestLogStoreOptions>(config,
"logging");
services.AddSingletonAs(_ => new ApplicationInfoLogAppender(typeof(LoggingServices).Assembly, Guid.NewGuid())) services.AddSingletonAs(_ => new ApplicationInfoLogAppender(typeof(LoggingServices).Assembly, Guid.NewGuid()))
.As<ILogAppender>(); .As<ILogAppender>();

3
backend/src/Squidex/Config/Domain/MigrationServices.cs

@ -17,7 +17,8 @@ namespace Squidex.Config.Domain
{ {
public static void AddSquidexMigration(this IServiceCollection services, IConfiguration config) public static void AddSquidexMigration(this IServiceCollection services, IConfiguration config)
{ {
services.Configure<RebuildOptions>(config, "rebuild"); services.Configure<RebuildOptions>(config,
"rebuild");
services.AddSingletonAs<Migrator>() services.AddSingletonAs<Migrator>()
.AsSelf(); .AsSelf();

3
backend/src/Squidex/Config/Domain/NotificationsServices.cs

@ -25,7 +25,8 @@ namespace Squidex.Config.Domain
{ {
services.AddSingleton(Options.Create(emailOptions)); services.AddSingleton(Options.Create(emailOptions));
services.Configure<NotificationEmailTextOptions>(config, "email:notifications"); services.Configure<NotificationEmailTextOptions>(config,
"email:notifications");
services.AddSingletonAs<SmtpEmailSender>() services.AddSingletonAs<SmtpEmailSender>()
.As<IEmailSender>(); .As<IEmailSender>();

13
backend/src/Squidex/Config/Domain/QueryServices.cs

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using GraphQL.DataLoader;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core;
@ -21,20 +20,12 @@ namespace Squidex.Config.Domain
{ {
var exposeSourceUrl = config.GetOptionalValue("assetStore:exposeSourceUrl", true); var exposeSourceUrl = config.GetOptionalValue("assetStore:exposeSourceUrl", true);
services.Configure<GraphQLOptions>(config, "graphql"); services.Configure<GraphQLOptions>(config,
"graphql");
services.AddSingletonAs(c => ActivatorUtilities.CreateInstance<UrlGenerator>(c, exposeSourceUrl)) services.AddSingletonAs(c => ActivatorUtilities.CreateInstance<UrlGenerator>(c, exposeSourceUrl))
.As<IUrlGenerator>(); .As<IUrlGenerator>();
services.AddSingletonAs<DataLoaderContextAccessor>()
.As<IDataLoaderContextAccessor>();
services.AddTransientAs<GraphQLExecutionContext>()
.AsSelf();
services.AddSingletonAs<DataLoaderDocumentListener>()
.AsSelf();
services.AddSingletonAs<SharedTypes>() services.AddSingletonAs<SharedTypes>()
.AsSelf(); .AsSelf();

3
backend/src/Squidex/Config/Domain/RuleServices.cs

@ -30,7 +30,8 @@ namespace Squidex.Config.Domain
{ {
public static void AddSquidexRules(this IServiceCollection services, IConfiguration config) public static void AddSquidexRules(this IServiceCollection services, IConfiguration config)
{ {
services.Configure<RuleOptions>(config, "rules"); services.Configure<RuleOptions>(config,
"rules");
services.AddTransientAs<RuleDomainObject>() services.AddTransientAs<RuleDomainObject>()
.AsSelf(); .AsSelf();

25
backend/src/Squidex/Config/Domain/SerializationServices.cs

@ -5,6 +5,10 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using GraphQL;
using GraphQL.Execution;
using GraphQL.NewtonsoftJson;
using GraphQL.Server;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Migrations; using Migrations;
using Newtonsoft.Json; using Newtonsoft.Json;
@ -15,6 +19,7 @@ using Squidex.Domain.Apps.Core.Contents.Json;
using Squidex.Domain.Apps.Core.Rules.Json; using Squidex.Domain.Apps.Core.Rules.Json;
using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Core.Schemas.Json; using Squidex.Domain.Apps.Core.Schemas.Json;
using Squidex.Domain.Apps.Entities.Contents.GraphQL;
using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Json; using Squidex.Infrastructure.Json;
@ -38,6 +43,7 @@ namespace Squidex.Config.Domain
new ContentFieldDataConverter(), new ContentFieldDataConverter(),
new DomainIdConverter(), new DomainIdConverter(),
new EnvelopeHeadersConverter(), new EnvelopeHeadersConverter(),
new ExecutionResultJsonConverter(new ErrorInfoProvider()),
new FilterConverter(), new FilterConverter(),
new InstantConverter(), new InstantConverter(),
new JsonValueConverter(), new JsonValueConverter(),
@ -115,16 +121,29 @@ namespace Squidex.Config.Domain
return services; return services;
} }
public static IMvcBuilder AddSquidexSerializers(this IMvcBuilder mvc) public static IMvcBuilder AddSquidexSerializers(this IMvcBuilder builder)
{ {
mvc.AddNewtonsoftJson(options => builder.AddNewtonsoftJson(options =>
{ {
options.AllowInputFormatterExceptionMessages = false; options.AllowInputFormatterExceptionMessages = false;
ConfigureJson(options.SerializerSettings, TypeNameHandling.None); ConfigureJson(options.SerializerSettings, TypeNameHandling.None);
}); });
return mvc; return builder;
}
public static IGraphQLBuilder AddSquidexWriter(this IGraphQLBuilder builder)
{
builder.Services.AddSingleton<IDocumentWriter>(c =>
{
var settings = ConfigureJson(new JsonSerializerSettings(), TypeNameHandling.None);
var serializer = new NewtonsoftJsonSerializer(settings);
return new DefaultDocumentWriter(serializer);
});
return builder;
} }
} }
} }

7
backend/src/Squidex/Config/Web/WebExtensions.cs

@ -23,6 +23,13 @@ namespace Squidex.Config.Web
{ {
public static class WebExtensions public static class WebExtensions
{ {
public static IApplicationBuilder UseSquidexCacheKeys(this IApplicationBuilder app)
{
app.UseMiddleware<CachingKeysMiddleware>();
return app;
}
public static IApplicationBuilder UseSquidexLocalCache(this IApplicationBuilder app) public static IApplicationBuilder UseSquidexLocalCache(this IApplicationBuilder app)
{ {
app.UseMiddleware<LocalCacheMiddleware>(); app.UseMiddleware<LocalCacheMiddleware>();

37
backend/src/Squidex/Config/Web/WebServices.cs

@ -5,6 +5,9 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using GraphQL;
using GraphQL.Server;
using GraphQL.Server.Transports.AspNetCore;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.Infrastructure;
@ -14,11 +17,11 @@ using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Squidex.Config.Domain; using Squidex.Config.Domain;
using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Contents.GraphQL;
using Squidex.Infrastructure.Caching; using Squidex.Infrastructure.Caching;
using Squidex.Infrastructure.Translations;
using Squidex.Pipeline.Plugins; using Squidex.Pipeline.Plugins;
using Squidex.Shared;
using Squidex.Web; using Squidex.Web;
using Squidex.Web.GraphQL;
using Squidex.Web.Pipeline; using Squidex.Web.Pipeline;
using Squidex.Web.Services; using Squidex.Web.Services;
@ -28,10 +31,6 @@ namespace Squidex.Config.Web
{ {
public static void AddSquidexMvcWithPlugins(this IServiceCollection services, IConfiguration config) public static void AddSquidexMvcWithPlugins(this IServiceCollection services, IConfiguration config)
{ {
var translator = new ResourcesLocalizer(Texts.ResourceManager);
T.Setup(translator);
services.AddDefaultWebServices(config); services.AddDefaultWebServices(config);
services.AddDefaultForwardRules(); services.AddDefaultForwardRules();
@ -53,9 +52,6 @@ namespace Squidex.Config.Web
services.AddSingletonAs<UsageMiddleware>() services.AddSingletonAs<UsageMiddleware>()
.AsSelf(); .AsSelf();
services.AddSingletonAs(c => translator)
.As<ILocalizer>();
services.AddSingletonAs<StringLocalizer>() services.AddSingletonAs<StringLocalizer>()
.As<IStringLocalizer>().As<IStringLocalizerFactory>(); .As<IStringLocalizer>().As<IStringLocalizerFactory>();
@ -91,5 +87,28 @@ namespace Squidex.Config.Web
.AddSquidexPlugins(config) .AddSquidexPlugins(config)
.AddSquidexSerializers(); .AddSquidexSerializers();
} }
public static void AddSquidexGraphQL(this IServiceCollection services)
{
services.AddGraphQL(options =>
{
options.EnableMetrics = false;
})
.AddDataLoader()
.AddSystemTextJson()
.AddSquidexWriter();
services.AddSingletonAs<DummySchema>()
.AsSelf();
services.AddSingletonAs<DynamicExecutor>()
.As<IDocumentExecuter>();
services.AddSingletonAs<DynamicUserContextBuilder>()
.As<IUserContextBuilder>();
services.AddSingletonAs<GraphQLMiddleware>()
.AsSelf();
}
} }
} }

5
backend/src/Squidex/Squidex.csproj

@ -34,7 +34,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AspNet.Security.OAuth.GitHub" Version="5.0.0" /> <PackageReference Include="AspNet.Security.OAuth.GitHub" Version="5.0.0" />
<PackageReference Include="GraphQL.NewtonsoftJson" Version="3.2.0" /> <PackageReference Include="GraphQL.Server.Transports.AspNetCore.NewtonsoftJson" Version="4.4.1" />
<PackageReference Include="GraphQL.Server.Transports.AspNetCore.SystemTextJson" Version="4.4.1" />
<PackageReference Include="IdentityServer4" Version="4.1.1" /> <PackageReference Include="IdentityServer4" Version="4.1.1" />
<PackageReference Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" /> <PackageReference Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" />
<PackageReference Include="IdentityServer4.AspNetIdentity" Version="4.1.1" /> <PackageReference Include="IdentityServer4.AspNetIdentity" Version="4.1.1" />
@ -64,7 +65,7 @@
<PackageReference Include="Squidex.Assets.Mongo" Version="1.3.0" /> <PackageReference Include="Squidex.Assets.Mongo" Version="1.3.0" />
<PackageReference Include="Squidex.Assets.S3" Version="1.3.0" /> <PackageReference Include="Squidex.Assets.S3" Version="1.3.0" />
<PackageReference Include="Squidex.Caching.Orleans" Version="1.8.0" /> <PackageReference Include="Squidex.Caching.Orleans" Version="1.8.0" />
<PackageReference Include="Squidex.ClientLibrary" Version="6.8.0" /> <PackageReference Include="Squidex.ClientLibrary" Version="6.12.0" />
<PackageReference Include="Squidex.Hosting" Version="1.8.0" /> <PackageReference Include="Squidex.Hosting" Version="1.8.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Linq" Version="4.3.0" /> <PackageReference Include="System.Linq" Version="4.3.0" />

3
backend/src/Squidex/Startup.cs

@ -51,11 +51,13 @@ namespace Squidex
services.AddSquidexControllerServices(config); services.AddSquidexControllerServices(config);
services.AddSquidexEventPublisher(config); services.AddSquidexEventPublisher(config);
services.AddSquidexEventSourcing(config); services.AddSquidexEventSourcing(config);
services.AddSquidexGraphQL();
services.AddSquidexHealthChecks(config); services.AddSquidexHealthChecks(config);
services.AddSquidexHistory(config); services.AddSquidexHistory(config);
services.AddSquidexIdentity(config); services.AddSquidexIdentity(config);
services.AddSquidexIdentityServer(); services.AddSquidexIdentityServer();
services.AddSquidexInfrastructure(config); services.AddSquidexInfrastructure(config);
services.AddSquidexLocalization();
services.AddSquidexMigration(config); services.AddSquidexMigration(config);
services.AddSquidexNotifications(config); services.AddSquidexNotifications(config);
services.AddSquidexOpenApiSettings(); services.AddSquidexOpenApiSettings();
@ -76,6 +78,7 @@ namespace Squidex
app.UseDefaultForwardRules(); app.UseDefaultForwardRules();
app.UseSquidexCacheKeys();
app.UseSquidexHealthCheck(); app.UseSquidexHealthCheck();
app.UseSquidexRobotsTxt(); app.UseSquidexRobotsTxt();
app.UseSquidexTracking(); app.UseSquidexTracking();

12
backend/tests/Squidex.Domain.Apps.Core.Tests/TestHelpers/TestUtils.cs

@ -32,7 +32,8 @@ namespace Squidex.Domain.Apps.Core.TestHelpers
public static readonly JsonSerializerSettings DefaultSerializerSettings = CreateSerializerSettings(); public static readonly JsonSerializerSettings DefaultSerializerSettings = CreateSerializerSettings();
public static JsonSerializerSettings CreateSerializerSettings(TypeNameHandling typeNameHandling = TypeNameHandling.Auto) public static JsonSerializerSettings CreateSerializerSettings(TypeNameHandling typeNameHandling = TypeNameHandling.Auto,
JsonConverter? converter = null)
{ {
var typeNameRegistry = var typeNameRegistry =
new TypeNameRegistry() new TypeNameRegistry()
@ -76,12 +77,17 @@ namespace Squidex.Domain.Apps.Core.TestHelpers
TypeNameHandling = typeNameHandling TypeNameHandling = typeNameHandling
}; };
if (converter != null)
{
serializerSettings.Converters.Add(converter);
}
return serializerSettings; return serializerSettings;
} }
public static IJsonSerializer CreateSerializer(TypeNameHandling typeNameHandling = TypeNameHandling.Auto) public static IJsonSerializer CreateSerializer(TypeNameHandling typeNameHandling = TypeNameHandling.Auto, JsonConverter? converter = null)
{ {
var serializerSettings = CreateSerializerSettings(typeNameHandling); var serializerSettings = CreateSerializerSettings(typeNameHandling, converter);
return new NewtonsoftJsonSerializer(serializerSettings); return new NewtonsoftJsonSerializer(serializerSettings);
} }

5
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLIntrospectionTests.cs

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using System.Threading.Tasks; using System.Threading.Tasks;
using GraphQL;
using Xunit; using Xunit;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
@ -94,9 +95,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
} }
}"; }";
var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query, OperationName = "IntrospectionQuery" }); var result = await ExecuteAsync(new ExecutionOptions { Query = query, OperationName = "IntrospectionQuery" });
var json = serializer.Serialize(result.Response, true); var json = serializer.Serialize(result.Data, true);
Assert.NotEmpty(json); Assert.NotEmpty(json);
} }

93
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLMutationTests.cs

@ -15,7 +15,6 @@ using Newtonsoft.Json.Linq;
using NodaTime; using NodaTime;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Contents.Commands; using Squidex.Domain.Apps.Entities.Contents.Commands;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Shared; using Squidex.Shared;
@ -47,14 +46,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
} }
}"; }";
var result = await ExecuteAsync(new GraphQLQuery { Query = query }, Permissions.AppContentsReadOwn); var result = await ExecuteAsync(new ExecutionOptions { Query = query }, Permissions.AppContentsReadOwn);
var expected = new var expected = new
{ {
data = new
{
createMySchemaContent = (object?)null
},
errors = new[] errors = new[]
{ {
new new
@ -67,6 +62,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
line = 3, line = 3,
column = 19 column = 19
} }
},
path = new[]
{
"createMySchemaContent"
} }
} }
} }
@ -90,7 +89,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
commandContext.Complete(content); commandContext.Complete(content);
var result = await ExecuteAsync(new GraphQLQuery { Query = query }, Permissions.AppContentsCreate); var result = await ExecuteAsync(new ExecutionOptions { Query = query }, Permissions.AppContentsCreate);
var expected = new var expected = new
{ {
@ -123,7 +122,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
commandContext.Complete(content); commandContext.Complete(content);
var result = await ExecuteAsync(new GraphQLQuery { Query = query }, Permissions.AppContentsCreate); var result = await ExecuteAsync(new ExecutionOptions { Query = query }, Permissions.AppContentsCreate);
var expected = new var expected = new
{ {
@ -157,7 +156,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
commandContext.Complete(content); commandContext.Complete(content);
var result = await ExecuteAsync( new GraphQLQuery { Query = query, Inputs = GetInput() }, Permissions.AppContentsCreate); var result = await ExecuteAsync( new ExecutionOptions { Query = query, Inputs = GetInput() }, Permissions.AppContentsCreate);
var expected = new var expected = new
{ {
@ -188,14 +187,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
} }
}".Replace("<ID>", contentId.ToString()); }".Replace("<ID>", contentId.ToString());
var result = await ExecuteAsync(new GraphQLQuery { Query = query }, Permissions.AppContentsReadOwn); var result = await ExecuteAsync(new ExecutionOptions { Query = query }, Permissions.AppContentsReadOwn);
var expected = new var expected = new
{ {
data = new
{
updateMySchemaContent = (object?)null
},
errors = new[] errors = new[]
{ {
new new
@ -208,6 +203,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
line = 3, line = 3,
column = 19 column = 19
} }
},
path = new[]
{
"updateMySchemaContent"
} }
} }
} }
@ -231,7 +230,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
commandContext.Complete(content); commandContext.Complete(content);
var result = await ExecuteAsync( new GraphQLQuery { Query = query }, Permissions.AppContentsUpdateOwn); var result = await ExecuteAsync( new ExecutionOptions { Query = query }, Permissions.AppContentsUpdateOwn);
var expected = new var expected = new
{ {
@ -263,7 +262,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
commandContext.Complete(content); commandContext.Complete(content);
var result = await ExecuteAsync( new GraphQLQuery { Query = query, Inputs = GetInput() }, Permissions.AppContentsUpdateOwn); var result = await ExecuteAsync( new ExecutionOptions { Query = query, Inputs = GetInput() }, Permissions.AppContentsUpdateOwn);
var expected = new var expected = new
{ {
@ -293,14 +292,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
} }
}".Replace("<ID>", contentId.ToString()); }".Replace("<ID>", contentId.ToString());
var result = await ExecuteAsync(new GraphQLQuery { Query = query }, Permissions.AppContentsReadOwn); var result = await ExecuteAsync(new ExecutionOptions { Query = query }, Permissions.AppContentsReadOwn);
var expected = new var expected = new
{ {
data = new
{
upsertMySchemaContent = (object?)null
},
errors = new[] errors = new[]
{ {
new new
@ -313,6 +308,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
line = 3, line = 3,
column = 19 column = 19
} }
},
path = new[]
{
"upsertMySchemaContent"
} }
} }
} }
@ -336,7 +335,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
commandContext.Complete(content); commandContext.Complete(content);
var result = await ExecuteAsync( new GraphQLQuery { Query = query }, Permissions.AppContentsUpsert); var result = await ExecuteAsync( new ExecutionOptions { Query = query }, Permissions.AppContentsUpsert);
var expected = new var expected = new
{ {
@ -369,7 +368,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
commandContext.Complete(content); commandContext.Complete(content);
var result = await ExecuteAsync( new GraphQLQuery { Query = query, Inputs = GetInput() }, Permissions.AppContentsUpsert); var result = await ExecuteAsync( new ExecutionOptions { Query = query, Inputs = GetInput() }, Permissions.AppContentsUpsert);
var expected = new var expected = new
{ {
@ -400,14 +399,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
} }
}".Replace("<ID>", contentId.ToString()); }".Replace("<ID>", contentId.ToString());
var result = await ExecuteAsync(new GraphQLQuery { Query = query }, Permissions.AppContentsReadOwn); var result = await ExecuteAsync(new ExecutionOptions { Query = query }, Permissions.AppContentsReadOwn);
var expected = new var expected = new
{ {
data = new
{
patchMySchemaContent = (object?)null
},
errors = new[] errors = new[]
{ {
new new
@ -420,6 +415,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
line = 3, line = 3,
column = 19 column = 19
} }
},
path = new[]
{
"patchMySchemaContent"
} }
} }
} }
@ -443,7 +442,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
commandContext.Complete(content); commandContext.Complete(content);
var result = await ExecuteAsync( new GraphQLQuery { Query = query }, Permissions.AppContentsUpdateOwn); var result = await ExecuteAsync( new ExecutionOptions { Query = query }, Permissions.AppContentsUpdateOwn);
var expected = new var expected = new
{ {
@ -475,7 +474,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
commandContext.Complete(content); commandContext.Complete(content);
var result = await ExecuteAsync( new GraphQLQuery { Query = query, Inputs = GetInput() }, Permissions.AppContentsUpdateOwn); var result = await ExecuteAsync( new ExecutionOptions { Query = query, Inputs = GetInput() }, Permissions.AppContentsUpdateOwn);
var expected = new var expected = new
{ {
@ -505,14 +504,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
} }
}".Replace("<ID>", contentId.ToString()); }".Replace("<ID>", contentId.ToString());
var result = await ExecuteAsync(new GraphQLQuery { Query = query }, Permissions.AppContentsReadOwn); var result = await ExecuteAsync(new ExecutionOptions { Query = query }, Permissions.AppContentsReadOwn);
var expected = new var expected = new
{ {
data = new
{
changeMySchemaContent = (object?)null
},
errors = new[] errors = new[]
{ {
new new
@ -525,6 +520,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
line = 3, line = 3,
column = 19 column = 19
} }
},
path = new[]
{
"changeMySchemaContent"
} }
} }
} }
@ -550,7 +549,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
commandContext.Complete(content); commandContext.Complete(content);
var result = await ExecuteAsync( new GraphQLQuery { Query = query }, Permissions.AppContentsChangeStatusOwn); var result = await ExecuteAsync( new ExecutionOptions { Query = query }, Permissions.AppContentsChangeStatusOwn);
var expected = new var expected = new
{ {
@ -583,7 +582,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
commandContext.Complete(content); commandContext.Complete(content);
var result = await ExecuteAsync( new GraphQLQuery { Query = query }, Permissions.AppContentsChangeStatusOwn); var result = await ExecuteAsync( new ExecutionOptions { Query = query }, Permissions.AppContentsChangeStatusOwn);
var expected = new var expected = new
{ {
@ -616,7 +615,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
commandContext.Complete(content); commandContext.Complete(content);
var result = await ExecuteAsync( new GraphQLQuery { Query = query }, Permissions.AppContentsChangeStatusOwn); var result = await ExecuteAsync( new ExecutionOptions { Query = query }, Permissions.AppContentsChangeStatusOwn);
var expected = new var expected = new
{ {
@ -647,11 +646,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
} }
}".Replace("<ID>", contentId.ToString()); }".Replace("<ID>", contentId.ToString());
var result = await ExecuteAsync(new GraphQLQuery { Query = query }, Permissions.AppContentsReadOwn); var result = await ExecuteAsync(new ExecutionOptions { Query = query }, Permissions.AppContentsReadOwn);
var expected = new var expected = new
{ {
data = (object?)null,
errors = new[] errors = new[]
{ {
new new
@ -664,6 +662,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
line = 3, line = 3,
column = 19 column = 19
} }
},
path = new[]
{
"deleteMySchemaContent"
} }
} }
} }
@ -687,7 +689,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
commandContext.Complete(new EntitySavedResult(13)); commandContext.Complete(new EntitySavedResult(13));
var result = await ExecuteAsync( new GraphQLQuery { Query = query }, Permissions.AppContentsDeleteOwn); var result = await ExecuteAsync( new ExecutionOptions { Query = query }, Permissions.AppContentsDeleteOwn);
var expected = new var expected = new
{ {
@ -709,15 +711,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
.MustHaveHappened(); .MustHaveHappened();
} }
private Task<(bool HasError, object Response)> ExecuteAsync(GraphQLQuery query, string permissionId)
{
var permission = Permissions.ForApp(permissionId, app.Name, schemaId.Name).Id;
var withPermission = new Context(Mocks.FrontendUser(permission: permission), app);
return sut.QueryAsync(withPermission, query);
}
private Inputs GetInput() private Inputs GetInput()
{ {
var input = new var input = new

110
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs

@ -7,6 +7,7 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
using GraphQL;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Domain.Apps.Entities.TestHelpers;
@ -18,17 +19,28 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
public class GraphQLQueriesTests : GraphQLTestBase public class GraphQLQueriesTests : GraphQLTestBase
{ {
[Theory] [Theory]
[InlineData(null)]
[InlineData("")] [InlineData("")]
[InlineData(" ")] [InlineData(" ")]
public async Task Should_return_empty_object_for_empty_query(string query) public async Task Should_return_error_empty_query(string query)
{ {
var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); var result = await ExecuteAsync(new ExecutionOptions { Query = query });
var expected = new var expected = new
{ {
data = new errors = new object[]
{ {
new
{
message = "Document does not contain any operations.",
extensions = new
{
code = "NO_OPERATION",
codes = new[]
{
"NO_OPERATION"
}
}
}
} }
}; };
@ -51,7 +63,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
A<Q>.That.Matches(x => x.ODataQuery == "?$top=30&$skip=5&$filter=my-query" && x.NoTotal == true))) A<Q>.That.Matches(x => x.ODataQuery == "?$top=30&$skip=5&$filter=my-query" && x.NoTotal == true)))
.Returns(ResultList.CreateFrom(0, asset)); .Returns(ResultList.CreateFrom(0, asset));
var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); var result = await ExecuteAsync(new ExecutionOptions { Query = query });
var expected = new var expected = new
{ {
@ -86,7 +98,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
A<Q>.That.Matches(x => x.ODataQuery == "?$top=30&$skip=5&$filter=my-query" && x.NoTotal == false))) A<Q>.That.Matches(x => x.ODataQuery == "?$top=30&$skip=5&$filter=my-query" && x.NoTotal == false)))
.Returns(ResultList.CreateFrom(10, asset)); .Returns(ResultList.CreateFrom(10, asset));
var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); var result = await ExecuteAsync(new ExecutionOptions { Query = query });
var expected = new var expected = new
{ {
@ -121,7 +133,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), null, A<Q>.That.HasIdsWithoutTotal(assetId))) A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), null, A<Q>.That.HasIdsWithoutTotal(assetId)))
.Returns(ResultList.CreateFrom<IEnrichedAssetEntity>(1)); .Returns(ResultList.CreateFrom<IEnrichedAssetEntity>(1));
var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); var result = await ExecuteAsync(new ExecutionOptions { Query = query });
var expected = new var expected = new
{ {
@ -150,7 +162,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), null, A<Q>.That.HasIdsWithoutTotal(assetId))) A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), null, A<Q>.That.HasIdsWithoutTotal(assetId)))
.Returns(ResultList.CreateFrom(1, asset)); .Returns(ResultList.CreateFrom(1, asset));
var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); var result = await ExecuteAsync(new ExecutionOptions { Query = query });
var expected = new var expected = new
{ {
@ -202,7 +214,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
A<Q>.That.Matches(x => x.ODataQuery == "?$top=30&$skip=5" && x.NoTotal == true))) A<Q>.That.Matches(x => x.ODataQuery == "?$top=30&$skip=5" && x.NoTotal == true)))
.Returns(ResultList.CreateFrom(0, content)); .Returns(ResultList.CreateFrom(0, content));
var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); var result = await ExecuteAsync(new ExecutionOptions { Query = query });
var expected = new var expected = new
{ {
@ -281,7 +293,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
A<Q>.That.Matches(x => x.ODataQuery == "?$top=30&$skip=5" && x.NoTotal == true))) A<Q>.That.Matches(x => x.ODataQuery == "?$top=30&$skip=5" && x.NoTotal == true)))
.Returns(ResultList.CreateFrom(0, content)); .Returns(ResultList.CreateFrom(0, content));
var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); var result = await ExecuteAsync(new ExecutionOptions { Query = query });
var expected = new var expected = new
{ {
@ -316,7 +328,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
A<Q>.That.Matches(x => x.ODataQuery == "?$top=30&$skip=5" && x.NoTotal == false))) A<Q>.That.Matches(x => x.ODataQuery == "?$top=30&$skip=5" && x.NoTotal == false)))
.Returns(ResultList.CreateFrom(10, content)); .Returns(ResultList.CreateFrom(10, content));
var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); var result = await ExecuteAsync(new ExecutionOptions { Query = query });
var expected = new var expected = new
{ {
@ -351,7 +363,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A<Q>.That.HasIdsWithoutTotal(contentId))) A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A<Q>.That.HasIdsWithoutTotal(contentId)))
.Returns(ResultList.CreateFrom<IEnrichedContentEntity>(1)); .Returns(ResultList.CreateFrom<IEnrichedContentEntity>(1));
var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); var result = await ExecuteAsync(new ExecutionOptions { Query = query });
var expected = new var expected = new
{ {
@ -380,7 +392,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A<Q>.That.HasIdsWithoutTotal(contentId))) A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A<Q>.That.HasIdsWithoutTotal(contentId)))
.Returns(ResultList.CreateFrom(1, content)); .Returns(ResultList.CreateFrom(1, content));
var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); var result = await ExecuteAsync(new ExecutionOptions { Query = query });
var expected = new var expected = new
{ {
@ -409,7 +421,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
A.CallTo(() => contentQuery.FindAsync(MatchsContentContext(), schemaId.Id.ToString(), contentId, 3)) A.CallTo(() => contentQuery.FindAsync(MatchsContentContext(), schemaId.Id.ToString(), contentId, 3))
.Returns(content); .Returns(content);
var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); var result = await ExecuteAsync(new ExecutionOptions { Query = query });
var expected = new var expected = new
{ {
@ -456,7 +468,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A<Q>.That.HasIdsWithoutTotal(contentId))) A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A<Q>.That.HasIdsWithoutTotal(contentId)))
.Returns(ResultList.CreateFrom(1, content)); .Returns(ResultList.CreateFrom(1, content));
var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); var result = await ExecuteAsync(new ExecutionOptions { Query = query });
var expected = new var expected = new
{ {
@ -523,7 +535,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
A<Q>.That.Matches(x => x.ODataQuery == "?$top=30&$skip=5" && x.Reference == contentRefId && x.NoTotal == true))) A<Q>.That.Matches(x => x.ODataQuery == "?$top=30&$skip=5" && x.Reference == contentRefId && x.NoTotal == true)))
.Returns(ResultList.CreateFrom(1, content)); .Returns(ResultList.CreateFrom(1, content));
var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); var result = await ExecuteAsync(new ExecutionOptions { Query = query });
var expected = new var expected = new
{ {
@ -587,7 +599,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
A<Q>.That.Matches(x => x.ODataQuery == "?$top=30&$skip=5" && x.Reference == contentRefId && x.NoTotal == false))) A<Q>.That.Matches(x => x.ODataQuery == "?$top=30&$skip=5" && x.Reference == contentRefId && x.NoTotal == false)))
.Returns(ResultList.CreateFrom(1, content)); .Returns(ResultList.CreateFrom(1, content));
var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); var result = await ExecuteAsync(new ExecutionOptions { Query = query });
var expected = new var expected = new
{ {
@ -660,7 +672,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A<Q>.That.HasIdsWithoutTotal(contentId))) A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A<Q>.That.HasIdsWithoutTotal(contentId)))
.Returns(ResultList.CreateFrom(1, content)); .Returns(ResultList.CreateFrom(1, content));
var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); var result = await ExecuteAsync(new ExecutionOptions { Query = query });
var expected = new var expected = new
{ {
@ -726,7 +738,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), null, A<Q>.That.HasIdsWithoutTotal(assetRefId))) A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), null, A<Q>.That.HasIdsWithoutTotal(assetRefId)))
.Returns(ResultList.CreateFrom(0, assetRef)); .Returns(ResultList.CreateFrom(0, assetRef));
var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); var result = await ExecuteAsync(new ExecutionOptions { Query = query });
var expected = new var expected = new
{ {
@ -755,62 +767,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
AssertResult(expected, result); AssertResult(expected, result);
} }
[Fact]
public async Task Should_make_multiple_queries()
{
var assetId1 = DomainId.NewGuid();
var assetId2 = DomainId.NewGuid();
var asset1 = TestAsset.Create(appId, assetId1);
var asset2 = TestAsset.Create(appId, assetId2);
var query1 = @"
query {
findAsset(id: ""<ID>"") {
id
}
}".Replace("<ID>", assetId1.ToString());
var query2 = @"
query {
findAsset(id: ""<ID>"") {
id
}
}".Replace("<ID>", assetId2.ToString());
A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), null, A<Q>.That.HasIdsWithoutTotal(assetId1)))
.Returns(ResultList.CreateFrom(0, asset1));
A.CallTo(() => assetQuery.QueryAsync(MatchsAssetContext(), null, A<Q>.That.HasIdsWithoutTotal(assetId2)))
.Returns(ResultList.CreateFrom(0, asset2));
var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query1 }, new GraphQLQuery { Query = query2 });
var expected = new object[]
{
new
{
data = new
{
findAsset = new
{
id = asset1.Id
}
}
},
new
{
data = new
{
findAsset = new
{
id = asset2.Id
}
}
}
};
AssertResult(expected, result);
}
[Fact] [Fact]
public async Task Should_not_return_data_when_field_not_part_of_content() public async Task Should_not_return_data_when_field_not_part_of_content()
{ {
@ -838,11 +794,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A<Q>.That.HasIdsWithoutTotal(contentId))) A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), A<Q>.That.HasIdsWithoutTotal(contentId)))
.Returns(ResultList.CreateFrom(1, content)); .Returns(ResultList.CreateFrom(1, content));
var result = await sut.QueryAsync(requestContext, new GraphQLQuery { Query = query }); var result = await ExecuteAsync(new ExecutionOptions { Query = query });
var json = serializer.Serialize(result); var json = serializer.Serialize(result);
Assert.Contains("\"data\":null", json); Assert.Contains("\"errors\"", json);
} }
private Context MatchsAssetContext() private Context MatchsAssetContext()

50
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs

@ -5,10 +5,13 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
using GraphQL;
using GraphQL.DataLoader; using GraphQL.DataLoader;
using GraphQL.Execution;
using GraphQL.NewtonsoftJson;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@ -27,6 +30,7 @@ using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Json; using Squidex.Infrastructure.Json;
using Squidex.Log; using Squidex.Log;
using Squidex.Shared;
using Xunit; using Xunit;
#pragma warning disable SA1401 // Fields must be private #pragma warning disable SA1401 // Fields must be private
@ -35,22 +39,24 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
{ {
public class GraphQLTestBase : IClassFixture<TranslationsFixture> public class GraphQLTestBase : IClassFixture<TranslationsFixture>
{ {
protected readonly IAppEntity app; protected readonly IJsonSerializer serializer =
TestUtils.CreateSerializer(TypeNameHandling.None,
new ExecutionResultJsonConverter(new ErrorInfoProvider()));
protected readonly IAssetQueryService assetQuery = A.Fake<IAssetQueryService>(); protected readonly IAssetQueryService assetQuery = A.Fake<IAssetQueryService>();
protected readonly ICommandBus commandBus = A.Fake<ICommandBus>(); protected readonly ICommandBus commandBus = A.Fake<ICommandBus>();
protected readonly IContentQueryService contentQuery = A.Fake<IContentQueryService>(); protected readonly IContentQueryService contentQuery = A.Fake<IContentQueryService>();
protected readonly IJsonSerializer serializer = TestUtils.CreateSerializer(TypeNameHandling.None);
protected readonly ISchemaEntity schema; protected readonly ISchemaEntity schema;
protected readonly ISchemaEntity schemaRef1; protected readonly ISchemaEntity schemaRef1;
protected readonly ISchemaEntity schemaRef2; protected readonly ISchemaEntity schemaRef2;
protected readonly ISchemaEntity schemaInvalidName; protected readonly ISchemaEntity schemaInvalidName;
protected readonly IAppEntity app;
protected readonly Context requestContext; protected readonly Context requestContext;
protected readonly NamedId<DomainId> appId = NamedId.Of(DomainId.NewGuid(), "my-app"); protected readonly NamedId<DomainId> appId = NamedId.Of(DomainId.NewGuid(), "my-app");
protected readonly NamedId<DomainId> schemaId = NamedId.Of(DomainId.NewGuid(), "my-schema"); protected readonly NamedId<DomainId> schemaId = NamedId.Of(DomainId.NewGuid(), "my-schema");
protected readonly NamedId<DomainId> schemaRefId1 = NamedId.Of(DomainId.NewGuid(), "my-ref-schema1"); protected readonly NamedId<DomainId> schemaRefId1 = NamedId.Of(DomainId.NewGuid(), "my-ref-schema1");
protected readonly NamedId<DomainId> schemaRefId2 = NamedId.Of(DomainId.NewGuid(), "my-ref-schema2"); protected readonly NamedId<DomainId> schemaRefId2 = NamedId.Of(DomainId.NewGuid(), "my-ref-schema2");
protected readonly NamedId<DomainId> schemaInvalidNameId = NamedId.Of(DomainId.NewGuid(), "content"); protected readonly NamedId<DomainId> schemaInvalidNameId = NamedId.Of(DomainId.NewGuid(), "content");
protected readonly IGraphQLService sut; protected readonly CachingGraphQLService sut;
public GraphQLTestBase() public GraphQLTestBase()
{ {
@ -123,22 +129,40 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
sut = CreateSut(); sut = CreateSut();
} }
protected void AssertResult(object expected, (bool HasErrors, object Response) result, bool checkErrors = true) protected void AssertResult(object expected, ExecutionResult result)
{ {
if (checkErrors && result.HasErrors) var resultJson = serializer.Serialize(result, true);
{
throw new InvalidOperationException(Serialize(result));
}
var resultJson = serializer.Serialize(result.Response, true);
var expectJson = serializer.Serialize(expected, true); var expectJson = serializer.Serialize(expected, true);
Assert.Equal(expectJson, resultJson); Assert.Equal(expectJson, resultJson);
} }
private string Serialize((bool HasErrors, object Response) result) protected Task<ExecutionResult> ExecuteAsync(ExecutionOptions options, string? permissionId = null)
{
var context = requestContext;
if (permissionId != null)
{
var permission = Permissions.ForApp(permissionId, app.Name, schemaId.Name).Id;
context = new Context(Mocks.FrontendUser(permission: permission), app);
}
return ExcecuteAsync(options, context);
}
private Task<ExecutionResult> ExcecuteAsync(ExecutionOptions options, Context context)
{ {
return serializer.Serialize(result); options.UserContext = ActivatorUtilities.CreateInstance<GraphQLExecutionContext>(sut.Services, context);
var listener = sut.Services.GetService<DataLoaderDocumentListener>();
if (listener != null)
{
options.Listeners.Add(listener);
}
return sut.ExecuteAsync(options);
} }
private CachingGraphQLService CreateSut() private CachingGraphQLService CreateSut()

5
backend/tests/Squidex.Infrastructure.Tests/Commands/LogCommandMiddlewareTests.cs

@ -39,6 +39,11 @@ namespace Squidex.Infrastructure.Commands
{ {
throw new NotSupportedException(); throw new NotSupportedException();
} }
public ISemanticLog CreateScope(ILogAppender appender)
{
throw new NotSupportedException();
}
} }
public LogCommandMiddlewareTests() public LogCommandMiddlewareTests()

257
backend/tests/Squidex.Web.Tests/Pipeline/CachingFilterTests.cs

@ -6,7 +6,6 @@
// ========================================================================== // ==========================================================================
using System.Collections.Generic; using System.Collections.Generic;
using System.Security.Claims;
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
@ -15,10 +14,7 @@ using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers; using Microsoft.Net.Http.Headers;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Security;
using Xunit; using Xunit;
namespace Squidex.Web.Pipeline namespace Squidex.Web.Pipeline
@ -49,138 +45,7 @@ namespace Squidex.Web.Pipeline
Result = new OkResult() Result = new OkResult()
}; };
sut = new CachingFilter(cachingManager, Options.Create(cachingOptions)); sut = new CachingFilter(cachingManager);
}
[Fact]
public async Task Should_not_append_etag_if_not_found()
{
await sut.OnActionExecutionAsync(executingContext, Next());
Assert.Equal(StringValues.Empty, httpContext.Response.Headers[HeaderNames.ETag]);
}
[Fact]
public async Task Should_append_authorization_header_as_vary()
{
await sut.OnActionExecutionAsync(executingContext, Next());
Assert.Equal("Auth-State", httpContext.Response.Headers[HeaderNames.Vary]);
}
[Fact]
public async Task Should_append_authorization_as_header_when_user_has_subject()
{
var identity = (ClaimsIdentity)httpContext.User.Identity!;
identity.AddClaim(new Claim(OpenIdClaims.Subject, "my-id"));
await sut.OnActionExecutionAsync(executingContext, Next());
Assert.Equal("Auth-State,Authorization", httpContext.Response.Headers[HeaderNames.Vary]);
}
[Fact]
public async Task Should_append_client_id_as_header_when_user_has_client_but_no_subject()
{
var identity = (ClaimsIdentity)httpContext.User.Identity!;
identity.AddClaim(new Claim(OpenIdClaims.ClientId, "my-client"));
await sut.OnActionExecutionAsync(executingContext, Next());
Assert.Equal("Auth-State,Auth-ClientId", httpContext.Response.Headers[HeaderNames.Vary]);
}
[Fact]
public async Task Should_not_append_null_header_as_vary()
{
await sut.OnActionExecutionAsync(executingContext, () =>
{
cachingManager.AddHeader(null!);
return Task.FromResult(executedContext);
});
Assert.Equal("Auth-State", httpContext.Response.Headers[HeaderNames.Vary]);
}
[Fact]
public async Task Should_not_append_empty_header_as_vary()
{
await sut.OnActionExecutionAsync(executingContext, () =>
{
cachingManager.AddHeader(string.Empty);
return Task.FromResult(executedContext);
});
Assert.Equal("Auth-State", httpContext.Response.Headers[HeaderNames.Vary]);
}
[Fact]
public async Task Should_append_custom_header_as_vary()
{
await sut.OnActionExecutionAsync(executingContext, () =>
{
cachingManager.AddHeader("X-Header");
return Task.FromResult(executedContext);
});
Assert.Equal("Auth-State,X-Header", httpContext.Response.Headers[HeaderNames.Vary]);
}
[Fact]
public async Task Should_not_append_etag_if_empty()
{
httpContext.Response.Headers[HeaderNames.ETag] = string.Empty;
await sut.OnActionExecutionAsync(executingContext, Next());
Assert.Equal(string.Empty, httpContext.Response.Headers[HeaderNames.ETag]);
}
[Fact]
public async Task Should_not_convert_strong_etag_if_disabled()
{
cachingOptions.StrongETag = true;
httpContext.Response.Headers[HeaderNames.ETag] = "13";
await sut.OnActionExecutionAsync(executingContext, Next());
Assert.Equal("13", httpContext.Response.Headers[HeaderNames.ETag]);
}
[Fact]
public async Task Should_not_convert_already_weak_tag()
{
httpContext.Response.Headers[HeaderNames.ETag] = "W/13";
await sut.OnActionExecutionAsync(executingContext, Next());
Assert.Equal("W/13", httpContext.Response.Headers[HeaderNames.ETag]);
}
[Fact]
public async Task Should_convert_strong_to_weak_tag()
{
httpContext.Response.Headers[HeaderNames.ETag] = "13";
await sut.OnActionExecutionAsync(executingContext, Next());
Assert.Equal("W/13", httpContext.Response.Headers[HeaderNames.ETag]);
}
[Fact]
public async Task Should_not_convert_empty_string_to_weak_tag()
{
httpContext.Response.Headers[HeaderNames.ETag] = string.Empty;
await sut.OnActionExecutionAsync(executingContext, Next());
Assert.Equal(string.Empty, httpContext.Response.Headers[HeaderNames.ETag]);
} }
[Fact] [Fact]
@ -189,7 +54,7 @@ namespace Squidex.Web.Pipeline
httpContext.Request.Method = HttpMethods.Get; httpContext.Request.Method = HttpMethods.Get;
httpContext.Request.Headers[HeaderNames.IfNoneMatch] = "W/13"; httpContext.Request.Headers[HeaderNames.IfNoneMatch] = "W/13";
httpContext.Response.Headers[HeaderNames.ETag] = "13"; httpContext.Response.Headers[HeaderNames.ETag] = "W/13";
await sut.OnActionExecutionAsync(executingContext, Next()); await sut.OnActionExecutionAsync(executingContext, Next());
@ -200,129 +65,15 @@ namespace Squidex.Web.Pipeline
public async Task Should_not_return_304_for_different_etags() public async Task Should_not_return_304_for_different_etags()
{ {
httpContext.Request.Method = HttpMethods.Get; httpContext.Request.Method = HttpMethods.Get;
httpContext.Request.Headers[HeaderNames.IfNoneMatch] = "W/11"; httpContext.Request.Headers[HeaderNames.IfNoneMatch] = "W/13";
httpContext.Response.Headers[HeaderNames.ETag] = "13"; httpContext.Response.Headers[HeaderNames.ETag] = "W/11";
await sut.OnActionExecutionAsync(executingContext, Next()); await sut.OnActionExecutionAsync(executingContext, Next());
Assert.Equal(200, ((StatusCodeResult)executedContext.Result).StatusCode); Assert.Equal(200, ((StatusCodeResult)executedContext.Result).StatusCode);
} }
[Fact]
public async Task Should_append_surrogate_keys()
{
var id1 = DomainId.NewGuid();
var id2 = DomainId.NewGuid();
cachingOptions.MaxSurrogateKeysSize = 100;
await sut.OnActionExecutionAsync(executingContext, () =>
{
cachingManager.AddDependency(id1, 12);
cachingManager.AddDependency(id2, 12);
return Task.FromResult(executedContext);
});
Assert.Equal($"{id1} {id2}", httpContext.Response.Headers["Surrogate-Key"]);
}
[Fact]
public async Task Should_append_surrogate_keys_if_just_enough_space_for_one()
{
var id1 = DomainId.NewGuid();
var id2 = DomainId.NewGuid();
cachingOptions.MaxSurrogateKeysSize = 36;
await sut.OnActionExecutionAsync(executingContext, () =>
{
cachingManager.AddDependency(id1, 12);
cachingManager.AddDependency(id2, 12);
return Task.FromResult(executedContext);
});
Assert.Equal($"{id1}", httpContext.Response.Headers["Surrogate-Key"]);
}
[Fact]
public async Task Should_not_append_surrogate_keys_if_maximum_is_exceeded()
{
var id1 = DomainId.NewGuid();
var id2 = DomainId.NewGuid();
cachingOptions.MaxSurrogateKeysSize = 20;
await sut.OnActionExecutionAsync(executingContext, () =>
{
cachingManager.AddDependency(id1, 12);
cachingManager.AddDependency(id2, 12);
return Task.FromResult(executedContext);
});
Assert.Equal(StringValues.Empty, httpContext.Response.Headers["Surrogate-Key"]);
}
[Fact]
public async Task Should_not_append_surrogate_keys_if_maximum_is_overriden()
{
var id1 = DomainId.NewGuid();
var id2 = DomainId.NewGuid();
httpContext.Request.Headers[CachingManager.SurrogateKeySizeHeader] = "20";
await sut.OnActionExecutionAsync(executingContext, () =>
{
cachingManager.AddDependency(id1, 12);
cachingManager.AddDependency(id2, 12);
return Task.FromResult(executedContext);
});
Assert.Equal(StringValues.Empty, httpContext.Response.Headers["Surrogate-Key"]);
}
[Fact]
public async Task Should_generate_etag_from_ids_and_versions()
{
var id1 = DomainId.NewGuid();
var id2 = DomainId.NewGuid();
await sut.OnActionExecutionAsync(executingContext, () =>
{
cachingManager.AddDependency(id1, 12);
cachingManager.AddDependency(id2, 12);
cachingManager.AddDependency(12);
return Task.FromResult(executedContext);
});
Assert.True(httpContext.Response.Headers[HeaderNames.ETag].ToString().Length > 20);
}
[Fact]
public async Task Should_not_generate_etag_when_already_added()
{
var id1 = DomainId.NewGuid();
var id2 = DomainId.NewGuid();
await sut.OnActionExecutionAsync(executingContext, () =>
{
cachingManager.AddDependency(id1, 12);
cachingManager.AddDependency(id2, 12);
cachingManager.AddDependency(12);
executedContext.HttpContext.Response.Headers[HeaderNames.ETag] = "W/20";
return Task.FromResult(executedContext);
});
Assert.Equal("W/20", httpContext.Response.Headers[HeaderNames.ETag]);
}
private ActionExecutionDelegate Next() private ActionExecutionDelegate Next()
{ {
return () => Task.FromResult(executedContext); return () => Task.FromResult(executedContext);

328
backend/tests/Squidex.Web.Tests/Pipeline/CachingKeysMiddlewareTests.cs

@ -0,0 +1,328 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using FakeItEasy;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Security;
using Xunit;
namespace Squidex.Web.Pipeline
{
public class CachingKeysMiddlewareTests
{
private readonly List<(object, Func<object, Task>)> callbacks = new List<(object, Func<object, Task>)>();
private readonly IHttpContextAccessor httpContextAccessor = A.Fake<IHttpContextAccessor>();
private readonly IHttpResponseBodyFeature httpResponseBodyFeature = A.Fake<IHttpResponseBodyFeature>();
private readonly IHttpResponseFeature httpResponseFeature = A.Fake<IHttpResponseFeature>();
private readonly HttpContext httpContext = new DefaultHttpContext();
private readonly CachingOptions cachingOptions = new CachingOptions();
private readonly CachingManager cachingManager;
private readonly RequestDelegate next;
private readonly CachingKeysMiddleware sut;
private bool isNextCalled;
public CachingKeysMiddlewareTests()
{
var headers = new HeaderDictionary();
A.CallTo(() => httpResponseFeature.Headers)
.Returns(headers);
A.CallTo(() => httpResponseFeature.OnStarting(A<Func<object, Task>>._, A<object>._))
.Invokes(c =>
{
callbacks.Add((
c.GetArgument<object>(1)!,
c.GetArgument<Func<object, Task>>(0)!));
});
A.CallTo(() => httpResponseBodyFeature.StartAsync(A<CancellationToken>._))
.Invokes(c =>
{
foreach (var (state, callback) in callbacks)
{
callback(state).Wait();
}
});
httpContext.Features.Set(httpResponseBodyFeature);
httpContext.Features.Set(httpResponseFeature);
next = context =>
{
isNextCalled = true;
return Task.CompletedTask;
};
A.CallTo(() => httpContextAccessor.HttpContext)
.Returns(httpContext);
cachingManager = new CachingManager(httpContextAccessor, Options.Create(cachingOptions));
sut = new CachingKeysMiddleware(cachingManager, Options.Create(cachingOptions), next);
}
[Fact]
public async Task Should_invoke_next()
{
await MakeRequestAsync();
Assert.True(isNextCalled);
}
[Fact]
public async Task Should_not_append_etag_if_not_found()
{
await MakeRequestAsync();
Assert.Equal(StringValues.Empty, httpContext.Response.Headers[HeaderNames.ETag]);
}
[Fact]
public async Task Should_append_authorization_header_as_vary()
{
await MakeRequestAsync();
Assert.Equal("Auth-State", httpContext.Response.Headers[HeaderNames.Vary]);
}
[Fact]
public async Task Should_append_authorization_as_header_when_user_has_subject()
{
var identity = (ClaimsIdentity)httpContext.User.Identity!;
identity.AddClaim(new Claim(OpenIdClaims.Subject, "my-id"));
await MakeRequestAsync();
Assert.Equal("Auth-State,Authorization", httpContext.Response.Headers[HeaderNames.Vary]);
}
[Fact]
public async Task Should_append_client_id_as_header_when_user_has_client_but_no_subject()
{
var identity = (ClaimsIdentity)httpContext.User.Identity!;
identity.AddClaim(new Claim(OpenIdClaims.ClientId, "my-client"));
await MakeRequestAsync();
Assert.Equal("Auth-State,Auth-ClientId", httpContext.Response.Headers[HeaderNames.Vary]);
}
[Fact]
public async Task Should_not_append_null_header_as_vary()
{
await MakeRequestAsync(() =>
{
cachingManager.AddHeader(null!);
});
Assert.Equal("Auth-State", httpContext.Response.Headers[HeaderNames.Vary]);
}
[Fact]
public async Task Should_not_append_empty_header_as_vary()
{
await MakeRequestAsync(() =>
{
cachingManager.AddHeader(string.Empty);
});
Assert.Equal("Auth-State", httpContext.Response.Headers[HeaderNames.Vary]);
}
[Fact]
public async Task Should_append_custom_header_as_vary()
{
await MakeRequestAsync(() =>
{
cachingManager.AddHeader("X-Header");
});
Assert.Equal("Auth-State,X-Header", httpContext.Response.Headers[HeaderNames.Vary]);
}
[Fact]
public async Task Should_not_append_etag_if_empty()
{
await MakeRequestAsync(() =>
{
httpContext.Response.Headers[HeaderNames.ETag] = string.Empty;
});
Assert.Equal(string.Empty, httpContext.Response.Headers[HeaderNames.ETag]);
}
[Fact]
public async Task Should_not_convert_strong_etag_if_disabled()
{
cachingOptions.StrongETag = true;
await MakeRequestAsync(() =>
{
httpContext.Response.Headers[HeaderNames.ETag] = "13";
});
Assert.Equal("13", httpContext.Response.Headers[HeaderNames.ETag]);
}
[Fact]
public async Task Should_not_convert_already_weak_tag()
{
await MakeRequestAsync(() =>
{
httpContext.Response.Headers[HeaderNames.ETag] = "W/13";
});
Assert.Equal("W/13", httpContext.Response.Headers[HeaderNames.ETag]);
}
[Fact]
public async Task Should_convert_strong_to_weak_tag()
{
await MakeRequestAsync(() =>
{
httpContext.Response.Headers[HeaderNames.ETag] = "13";
});
Assert.Equal("W/13", httpContext.Response.Headers[HeaderNames.ETag]);
}
[Fact]
public async Task Should_not_convert_empty_string_to_weak_tag()
{
httpContext.Response.Headers[HeaderNames.ETag] = string.Empty;
await MakeRequestAsync();
Assert.Equal(string.Empty, httpContext.Response.Headers[HeaderNames.ETag]);
}
[Fact]
public async Task Should_append_surrogate_keys()
{
var id1 = DomainId.NewGuid();
var id2 = DomainId.NewGuid();
cachingOptions.MaxSurrogateKeysSize = 100;
await MakeRequestAsync(() =>
{
cachingManager.AddDependency(id1, 12);
cachingManager.AddDependency(id2, 12);
});
Assert.Equal($"{id1} {id2}", httpContext.Response.Headers["Surrogate-Key"]);
}
[Fact]
public async Task Should_append_surrogate_keys_if_just_enough_space_for_one()
{
var id1 = DomainId.NewGuid();
var id2 = DomainId.NewGuid();
cachingOptions.MaxSurrogateKeysSize = 36;
await MakeRequestAsync(() =>
{
cachingManager.AddDependency(id1, 12);
cachingManager.AddDependency(id2, 12);
});
Assert.Equal($"{id1}", httpContext.Response.Headers["Surrogate-Key"]);
}
[Fact]
public async Task Should_not_append_surrogate_keys_if_maximum_is_exceeded()
{
var id1 = DomainId.NewGuid();
var id2 = DomainId.NewGuid();
cachingOptions.MaxSurrogateKeysSize = 20;
await MakeRequestAsync(() =>
{
cachingManager.AddDependency(id1, 12);
cachingManager.AddDependency(id2, 12);
});
Assert.Equal(StringValues.Empty, httpContext.Response.Headers["Surrogate-Key"]);
}
[Fact]
public async Task Should_not_append_surrogate_keys_if_maximum_is_overriden()
{
var id1 = DomainId.NewGuid();
var id2 = DomainId.NewGuid();
httpContext.Request.Headers[CachingManager.SurrogateKeySizeHeader] = "20";
await MakeRequestAsync(() =>
{
cachingManager.AddDependency(id1, 12);
cachingManager.AddDependency(id2, 12);
});
Assert.Equal(StringValues.Empty, httpContext.Response.Headers["Surrogate-Key"]);
}
[Fact]
public async Task Should_generate_etag_from_ids_and_versions()
{
var id1 = DomainId.NewGuid();
var id2 = DomainId.NewGuid();
await MakeRequestAsync(() =>
{
cachingManager.AddDependency(id1, 12);
cachingManager.AddDependency(id2, 12);
cachingManager.AddDependency(12);
});
Assert.True(httpContext.Response.Headers[HeaderNames.ETag].ToString().Length > 20);
}
[Fact]
public async Task Should_not_generate_etag_when_already_added()
{
var id1 = DomainId.NewGuid();
var id2 = DomainId.NewGuid();
await MakeRequestAsync(() =>
{
cachingManager.AddDependency(DomainId.NewGuid(), 12);
cachingManager.AddDependency(DomainId.NewGuid(), 12);
cachingManager.AddDependency(12);
httpContext.Response.Headers[HeaderNames.ETag] = "W/20";
});
Assert.Equal("W/20", httpContext.Response.Headers[HeaderNames.ETag]);
}
private async Task MakeRequestAsync(Action? action = null)
{
await sut.InvokeAsync(httpContext);
action?.Invoke();
await httpContext.Response.StartAsync();
}
}
}

50
backend/tools/TestSuite/TestSuite.ApiTests/ContentQueryTests.cs

@ -440,6 +440,56 @@ namespace TestSuite.ApiTests
Assert.Equal(998, value); Assert.Equal(998, value);
} }
[Fact]
public async Task Should_batch_query_items_with_graphql()
{
var query1 = new
{
query = @"
query ContentsQuery($filter: String!) {
queryMyReadsContents(filter: $filter, orderby: ""data/number/iv asc"") {
id,
data {
number {
iv
}
}
}
}",
variables = new
{
filter = @"data/number/iv gt 3 and data/number/iv lt 7"
}
};
var query2 = new
{
query = @"
query ContentsQuery($filter: String!) {
queryMyReadsContents(filter: $filter, orderby: ""data/number/iv asc"") {
id,
data {
number {
iv
}
}
}
}",
variables = new
{
filter = @"data/number/iv gt 4 and data/number/iv lt 7"
}
};
var results = await _.Contents.GraphQlAsync<QueryResult>(new[] { query1, query2 });
var items1 = results.ElementAt(0).Data.Items;
var items2 = results.ElementAt(1).Data.Items;
Assert.Equal(items1.Select(x => x.Data.Number).ToArray(), new[] { 4, 5, 6 });
Assert.Equal(items2.Select(x => x.Data.Number).ToArray(), new[] { 5, 6 });
}
[Fact] [Fact]
public async Task Should_query_items_with_graphql() public async Task Should_query_items_with_graphql()
{ {

4
backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj

@ -4,11 +4,11 @@
<TargetFramework>net5.0</TargetFramework> <TargetFramework>net5.0</TargetFramework>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.6.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.3" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.1"> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference> </PackageReference>

4
backend/tools/TestSuite/TestSuite.LoadTests/TestSuite.LoadTests.csproj

@ -4,11 +4,11 @@
<TargetFramework>net5.0</TargetFramework> <TargetFramework>net5.0</TargetFramework>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.6.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.3" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.1"> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference> </PackageReference>

2
backend/tools/TestSuite/TestSuite.Shared/Fixtures/ContentReferencesFixture.cs

@ -33,7 +33,7 @@ namespace TestSuite.Fixtures
throw; throw;
} }
} }
}).Wait(); }).Wait();
Contents = ClientManager.CreateContentsClient<TestEntityWithReferences, TestEntityWithReferencesData>(SchemaName); Contents = ClientManager.CreateContentsClient<TestEntityWithReferences, TestEntityWithReferencesData>(SchemaName);
} }

6
backend/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj

@ -5,13 +5,13 @@
<RootNamespace>TestSuite</RootNamespace> <RootNamespace>TestSuite</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Fody" Version="6.1.1"> <PackageReference Include="Fody" Version="6.3.0">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Lazy.Fody" Version="1.8.0" PrivateAssets="all" /> <PackageReference Include="Lazy.Fody" Version="1.9.0" PrivateAssets="all" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.ClientLibrary" Version="6.4.0" /> <PackageReference Include="Squidex.ClientLibrary" Version="6.11.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit" Version="2.4.1" />
</ItemGroup> </ItemGroup>

Loading…
Cancel
Save