Browse Source

Merge branch 'next' into feature/ng8

# Conflicts:
#	src/Squidex/app/features/api/pages/graphql/graphql-page.component.scss
#	src/Squidex/app/features/apps/pages/news-dialog.component.scss
#	src/Squidex/app/features/apps/pages/onboarding-dialog.component.scss
#	src/Squidex/app/features/assets/pages/assets-page.component.scss
#	src/Squidex/app/features/content/pages/content/content-history-page.component.scss
#	src/Squidex/app/features/content/pages/contents/contents-page.component.scss
#	src/Squidex/app/features/content/shared/array-item.component.scss
#	src/Squidex/app/features/content/shared/contents-selector.component.scss
#	src/Squidex/app/features/dashboard/pages/dashboard-page.component.scss
#	src/Squidex/app/features/schemas/pages/schema/field-wizard.component.scss
#	src/Squidex/app/features/schemas/pages/schemas/schema-form.component.scss
#	src/Squidex/app/framework/angular/forms/code-editor.component.scss
#	src/Squidex/app/framework/angular/forms/color-picker.component.scss
#	src/Squidex/app/framework/angular/forms/json-editor.component.scss
#	src/Squidex/app/shared/components/asset.component.scss
#	src/Squidex/app/shared/components/assets-selector.component.scss
#	src/Squidex/app/shared/components/history-list.component.scss
#	src/Squidex/app/shared/state/assets.state.spec.ts
#	src/Squidex/package-lock.json
#	src/Squidex/package.json
pull/376/head
Sebastian Stehle 7 years ago
parent
commit
21100b0eae
  1. 6
      .testrunsettings
  2. 28
      CHANGELOG.md
  3. 12
      Dockerfile
  4. 12
      Dockerfile.build
  5. 5
      README.md
  6. 1
      Squidex.ruleset
  7. 8
      src/Squidex.Domain.Apps.Core.Model/Apps/Role.cs
  8. 42
      src/Squidex.Domain.Apps.Core.Model/Contents/Json/StatusConverter.cs
  9. 53
      src/Squidex.Domain.Apps.Core.Model/Contents/Status.cs
  10. 5
      src/Squidex.Domain.Apps.Core.Model/Contents/StatusChange.cs
  11. 32
      src/Squidex.Domain.Apps.Core.Model/Contents/StatusFlow.cs
  12. 9
      src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs
  13. 5
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedContentEventType.cs
  14. 4
      src/Squidex.Domain.Apps.Core.Operations/Scripting/JintUser.cs
  15. 6
      src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/FieldValidator.cs
  16. 15
      src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ObjectValidator.cs
  17. 7
      src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs
  18. 9
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs
  19. 1
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs
  20. 11
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs
  21. 39
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/StatusSerializer.cs
  22. 6
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FilterFactory.cs
  23. 20
      src/Squidex.Domain.Apps.Entities/Apps/AppExtensions.cs
  24. 62
      src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs
  25. 14
      src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByUserIndexCommandMiddleware.cs
  26. 4
      src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs
  27. 4
      src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitedResult.cs
  28. 2
      src/Squidex.Domain.Apps.Entities/Apps/RoleExtensions.cs
  29. 3
      src/Squidex.Domain.Apps.Entities/Apps/Services/IAppPlanBillingManager.cs
  30. 3
      src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/NoopAppPlanBillingManager.cs
  31. 80
      src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs
  32. 19
      src/Squidex.Domain.Apps.Entities/Assets/AssetCreatedResult.cs
  33. 26
      src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs
  34. 25
      src/Squidex.Domain.Apps.Entities/Assets/AssetResult.cs
  35. 25
      src/Squidex.Domain.Apps.Entities/Assets/AssetSavedResult.cs
  36. 9
      src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs
  37. 9
      src/Squidex.Domain.Apps.Entities/Assets/Commands/UpdateAsset.cs
  38. 20
      src/Squidex.Domain.Apps.Entities/Assets/Commands/UploadAssetCommand.cs
  39. 35
      src/Squidex.Domain.Apps.Entities/Assets/Edm/EdmAssetModel.cs
  40. 6
      src/Squidex.Domain.Apps.Entities/Comments/CommentsGrain.cs
  41. 7
      src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs
  42. 23
      src/Squidex.Domain.Apps.Entities/Contents/ContentDataChangedResult.cs
  43. 21
      src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs
  44. 64
      src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs
  45. 62
      src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs
  46. 54
      src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs
  47. 41
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs
  48. 99
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs
  49. 18
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs
  50. 2
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphModel.cs
  51. 42
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/LoggingMiddleware.cs
  52. 4
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AllTypes.cs
  53. 59
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphType.cs
  54. 4
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs
  55. 50
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Extensions.cs
  56. 41
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/NestedGraphType.cs
  57. 6
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/QueryGraphTypeVisitor.cs
  58. 35
      src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs
  59. 5
      src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs
  60. 26
      src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs
  61. 12
      src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs
  62. 10
      src/Squidex.Domain.Apps.Entities/Contents/ScheduleJob.cs
  63. 5
      src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs
  64. 2
      src/Squidex.Domain.Apps.Entities/Contents/StatusForApi.cs
  65. 21
      src/Squidex.Domain.Apps.Entities/EntityExtensions.cs
  66. 5
      src/Squidex.Domain.Apps.Entities/Q.cs
  67. 19
      src/Squidex.Domain.Apps.Entities/QueryContext.cs
  68. 18
      src/Squidex.Domain.Apps.Entities/Rules/RuleGrain.cs
  69. 24
      src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardHelper.cs
  70. 86
      src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs
  71. 50
      src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchemaField.cs
  72. 78
      src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs
  73. 2
      src/Squidex.Domain.Apps.Events/Contents/ContentCreated.cs
  74. 2
      src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs
  75. 16
      src/Squidex.Domain.Users/UserManagerExtensions.cs
  76. 6
      src/Squidex.Infrastructure.MongoDb/Assets/MongoGridFsAssetStore.cs
  77. 158
      src/Squidex.Infrastructure/Assets/FTPAssetStore.cs
  78. 6
      src/Squidex.Infrastructure/Assets/FolderAssetStore.cs
  79. 2
      src/Squidex.Infrastructure/Assets/MemoryAssetStore.cs
  80. 8
      src/Squidex.Infrastructure/CollectionExtensions.cs
  81. 12
      src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs
  82. 29
      src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs
  83. 12
      src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerManagerGrain.cs
  84. 6
      src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerGrain.cs
  85. 10
      src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerManagerGrain.cs
  86. 1
      src/Squidex.Infrastructure/Squidex.Infrastructure.csproj
  87. 10
      src/Squidex.Shared/Permissions.cs
  88. 2
      src/Squidex.Web/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs
  89. 2
      src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs
  90. 8
      src/Squidex.Web/Extensions.cs
  91. 76
      src/Squidex.Web/PermissionExtensions.cs
  92. 5
      src/Squidex.Web/Pipeline/AppResolver.cs
  93. 61
      src/Squidex.Web/Resource.cs
  94. 16
      src/Squidex.Web/ResourceLink.cs
  95. 44
      src/Squidex.Web/UrlHelperExtensions.cs
  96. 70
      src/Squidex/Areas/Api/Config/Swagger/ErrorDtoProcessor.cs
  97. 2
      src/Squidex/Areas/Api/Config/Swagger/FixProcessor.cs
  98. 29
      src/Squidex/Areas/Api/Config/Swagger/SecurityProcessor.cs
  99. 7
      src/Squidex/Areas/Api/Config/Swagger/SwaggerServices.cs
  100. 52
      src/Squidex/Areas/Api/Config/Swagger/XmlResponseTypesProcessor.cs

6
.testrunsettings

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
<RunConfiguration>
<MaxCpuCount>4</MaxCpuCount>
</RunConfiguration>
</RunSettings>

28
CHANGELOG.md

@ -1,5 +1,33 @@
# Changelog
## v2.1.0 - 2019-06-22
## Features
* **Assets**: Parameter to prevent download in Browser.
* **Assets**: FTP asset store.
* **GraphQL**: Logging for field resolvers
* **GraphQL**: Performance optimizations for asset fields and references with DataLoader.
* **MongoDB**: Performance optimizations.
* **MongoDB**: Support for AWS DocumentDB.
* **Schemas**: Separator field.
* **Schemas**: Setting to prevent duplicate references.
* **UI**: Improved styling of DateTime editor.
* **UI**: Custom Editors: Provide all values.
* **UI**: Custom Editors: Provide context with user information and auth token.
* **UI**: Filter by status.
* **UI**: Dropdown field for references.
* **Users**: Email notifications when contributors is added.
## Bugfixes
* **Contents**: Fix for scheduled publishing.
* **GraphQL**: Fix query parameters for assets.
* **GraphQL**: Fix for duplicate field names in GraphQL.
* **GraphQL**: Fix for invalid field names.
* **Plans**: Fix when plans reset and extra events.
* **UI**: Unify slugify in Frontend and Backend.
## v2.0.5 - 2019-04-21
## Features

12
Dockerfile

@ -5,11 +5,20 @@ FROM squidex/dotnet:2.2-sdk-chromium-phantomjs-node as builder
WORKDIR /src
# Copy Node project files.
COPY src/Squidex/package*.json /tmp/
# Install Node packages
RUN cd /tmp && npm install --loglevel=error
# Copy nuget project files.
COPY /**/**/*.csproj /tmp/
# Copy nuget.config for package sources.
COPY NuGet.Config /tmp/
# Install nuget packages
RUN bash -c 'pushd /tmp; for p in *.csproj; do dotnet restore $p --verbosity quiet; true; done; popd'
COPY . .
# Build Frontend
@ -19,8 +28,7 @@ RUN cp -a /tmp/node_modules src/Squidex/ \
&& npm run build
# Test Backend
RUN dotnet restore \
&& dotnet test --filter Category!=Dependencies tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj \
RUN dotnet test tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj --filter Category!=Dependencies \
&& dotnet test tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj \
&& dotnet test tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj \
&& dotnet test tests/Squidex.Domain.Users.Tests/Squidex.Domain.Users.Tests.csproj \

12
Dockerfile.build

@ -2,11 +2,20 @@ FROM squidex/dotnet:2.2-sdk-chromium-phantomjs-node as builder
WORKDIR /src
# Copy Node project files.
COPY src/Squidex/package*.json /tmp/
# Install Node packages
RUN cd /tmp && npm install --loglevel=error
# Copy Dotnet project files.
COPY /**/**/*.csproj /tmp/
# Copy nuget.config for package sources.
COPY NuGet.Config /tmp/
# Install Dotnet packages
RUN bash -c 'pushd /tmp; for p in *.csproj; do dotnet restore $p --verbosity quiet; true; done; popd'
COPY . .
# Build Frontend
@ -16,8 +25,7 @@ RUN cp -a /tmp/node_modules src/Squidex/ \
&& npm run build
# Test Backend
RUN dotnet restore \
&& dotnet test --filter Category!=Dependencies tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj \
RUN dotnet test tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj --filter Category!=Dependencies \
&& dotnet test tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj \
&& dotnet test tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj \
&& dotnet test tests/Squidex.Domain.Users.Tests/Squidex.Domain.Users.Tests.csproj \

5
README.md

@ -34,9 +34,10 @@ Current Version v2.0.4. Roadmap: https://trello.com/b/KakM4F3S/squidex-roadmap
### Contributors
* [pushrbx](https://pushrbx.net/): Azure Store support.
* [cpmstars](https://www.cpmstars.com): Asset support for rich editor.
* [civicplus](https://www.civicplus.com/) ([Avd6977](https://github.com/Avd6977), [dsbegnoce](https://github.com/dsbegnoche)): Google Maps support, custom regex patterns and a lot of small improvements.
* [cpmstars](https://www.cpmstars.com): Asset support for rich editor.
* [guohai](https://github.com/seamys): FTP asset store support, Email rule support, custom editors and bug fixes.
* [pushrbx](https://pushrbx.net/): Azure Store support.
* [razims](https://github.com/razims): GridFS support.
## Contributing

1
Squidex.ruleset

@ -63,6 +63,7 @@
<Rule Id="SA1601" Action="None" />
<Rule Id="SA1413" Action="None" />
<Rule Id="SA0001" Action="None" />
<Rule Id="SA1602" Action="None" />
</Rules>
<Rules AnalyzerId="RefactoringEssentials" RuleNamespace="RefactoringEssentials">
<Rule Id="RECS0061" Action="Error" />

8
src/Squidex.Domain.Apps.Core.Model/Apps/Role.cs

@ -7,6 +7,7 @@
using Squidex.Infrastructure;
using Squidex.Infrastructure.Security;
using System;
using System.Collections.Generic;
using System.Diagnostics.Contracts;
using P = Squidex.Shared.Permissions;
@ -20,7 +21,7 @@ namespace Squidex.Domain.Apps.Core.Apps
public const string Owner = "Owner";
public const string Reader = "Reader";
private static readonly HashSet<string> DefaultRolesSet = new HashSet<string>
private static readonly HashSet<string> DefaultRolesSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
Editor,
Developer,
@ -54,6 +55,11 @@ namespace Squidex.Domain.Apps.Core.Apps
return role != null && DefaultRolesSet.Contains(role);
}
public static bool IsRole(string name, string expected)
{
return name != null && string.Equals(name, expected, StringComparison.OrdinalIgnoreCase);
}
public static Role CreateOwner(string app)
{
return new Role(Owner,

42
src/Squidex.Domain.Apps.Core.Model/Contents/Json/StatusConverter.cs

@ -0,0 +1,42 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Newtonsoft.Json;
using Squidex.Infrastructure.Json.Newtonsoft;
using System;
using System.Collections.Generic;
namespace Squidex.Domain.Apps.Core.Contents.Json
{
public sealed class StatusConverter : JsonConverter, ISupportedTypes
{
public IEnumerable<Type> SupportedTypes
{
get { yield return typeof(Status); }
}
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
writer.WriteValue(value.ToString());
}
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
if (reader.TokenType != JsonToken.String)
{
throw new JsonException($"Expected String, but got {reader.TokenType}.");
}
return new Status(reader.Value.ToString());
}
public override bool CanConvert(Type objectType)
{
return objectType == typeof(Status);
}
}
}

53
src/Squidex.Domain.Apps.Core.Model/Contents/Status.cs

@ -5,12 +5,57 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
using System;
namespace Squidex.Domain.Apps.Core.Contents
{
public enum Status
public struct Status : IEquatable<Status>
{
Draft,
Archived,
Published
public static readonly Status Archived = new Status("Archived");
public static readonly Status Draft = new Status("Draft");
public static readonly Status Published = new Status("Published");
private readonly string name;
public string Name
{
get { return name ?? "Unknown"; }
}
public Status(string name)
{
this.name = name;
}
public override bool Equals(object obj)
{
return obj is Status status && Equals(status);
}
public bool Equals(Status other)
{
return string.Equals(name, other.name);
}
public override int GetHashCode()
{
return name?.GetHashCode() ?? 0;
}
public override string ToString()
{
return name;
}
public static bool operator ==(Status lhs, Status rhs)
{
return lhs.Equals(rhs);
}
public static bool operator !=(Status lhs, Status rhs)
{
return !lhs.Equals(rhs);
}
}
}

5
src/Squidex.Domain.Apps.Core.Model/Contents/StatusChange.cs

@ -1,7 +1,7 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
@ -9,9 +9,8 @@ namespace Squidex.Domain.Apps.Core.Contents
{
public enum StatusChange
{
Archived,
Change,
Published,
Restored,
Unpublished
}
}

32
src/Squidex.Domain.Apps.Core.Model/Contents/StatusFlow.cs

@ -1,32 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Linq;
namespace Squidex.Domain.Apps.Core.Contents
{
public static class StatusFlow
{
private static readonly Dictionary<Status, Status[]> Flow = new Dictionary<Status, Status[]>
{
[Status.Draft] = new[] { Status.Published, Status.Archived },
[Status.Archived] = new[] { Status.Draft },
[Status.Published] = new[] { Status.Draft, Status.Archived }
};
public static bool Exists(Status status)
{
return Flow.ContainsKey(status);
}
public static bool CanChange(Status status, Status toStatus)
{
return Flow.TryGetValue(status, out var state) && state.Contains(toStatus);
}
}
}

9
src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs

@ -122,9 +122,14 @@ namespace Squidex.Domain.Apps.Core.Schemas
{
Guard.NotNull(field, nameof(field));
if (ByName.ContainsKey(field.Name) || ById.ContainsKey(field.Id))
if (ByName.ContainsKey(field.Name))
{
throw new ArgumentException($"A field with name '{field.Name}' and id {field.Id} already exists.", nameof(field));
throw new ArgumentException($"A field with name '{field.Name}' already exists.", nameof(field));
}
if (ById.ContainsKey(field.Id))
{
throw new ArgumentException($"A field with id {field.Id} already exists.", nameof(field));
}
return Clone(clone =>

5
src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedContentEventType.cs

@ -9,12 +9,11 @@ namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents
{
public enum EnrichedContentEventType
{
Archived,
Created,
Deleted,
Published,
Restored,
StatusChanged,
Updated,
Unpublished,
Updated
}
}

4
src/Squidex.Domain.Apps.Core.Operations/Scripting/JintUser.cs

@ -48,9 +48,9 @@ namespace Squidex.Domain.Apps.Core.Scripting
private static ObjectWrapper CreateUser(Engine engine, string id, bool isClient, string email, string name, IEnumerable<Claim> allClaims)
{
var claims =
allClaims.GroupBy(x => x.Type)
allClaims.GroupBy(x => x.Type.Split(ClaimSeparators).Last())
.ToDictionary(
x => x.Key.Split(ClaimSeparators).Last(),
x => x.Key,
x => x.Select(y => y.Value).ToArray());
return new ObjectWrapper(engine, new { id, isClient, email, name, claims });

6
src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/FieldValidator.cs

@ -34,11 +34,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators
{
var typedValue = value;
if (value == null)
{
typedValue = Undefined.Value;
}
else if (value is IJsonValue jsonValue)
if (value is IJsonValue jsonValue)
{
if (jsonValue.Type == JsonValueType.Null)
{

15
src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ObjectValidator.cs

@ -26,7 +26,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators
public async Task ValidateAsync(object value, ValidationContext context, AddError addError)
{
if (value == null)
if (value.IsNullOrUndefined())
{
value = DefaultValue;
}
@ -49,17 +49,22 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators
{
var name = field.Key;
if (!values.TryGetValue(name, out var fieldValue))
var (isOptional, validator) = field.Value;
var fieldValue = Undefined.Value;
if (!values.TryGetValue(name, out var temp))
{
if (isPartial)
{
continue;
}
fieldValue = default;
}
else
{
fieldValue = temp;
}
var (isOptional, validator) = field.Value;
var fieldContext = context.Nested(name).Optional(isOptional);
tasks.Add(validator.ValidateAsync(fieldValue, fieldContext, addError));

7
src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs

@ -119,12 +119,9 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
{
var find = Collection.Find(x => ids.Contains(x.Id)).SortByDescending(x => x.LastModified);
var assetItems = find.ToListAsync();
var assetCount = find.CountDocumentsAsync();
var assetItems = await find.ToListAsync();
await Task.WhenAll(assetItems, assetCount);
return ResultList.Create(assetCount.Result, assetItems.Result.OfType<IAssetEntity>());
return ResultList.Create(assetItems.Count, assetItems.OfType<IAssetEntity>());
}
}

9
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs

@ -137,17 +137,14 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{
var find = Collection.Find(FilterFactory.IdsBySchema(schema.Id, ids, status));
var contentItems = find.WithoutDraft(includeDraft).ToListAsync();
var contentCount = find.CountDocumentsAsync();
await Task.WhenAll(contentItems, contentCount);
var contentItems = await find.WithoutDraft(includeDraft).ToListAsync();
foreach (var entity in contentItems.Result)
foreach (var entity in contentItems)
{
entity.ParseData(schema.SchemaDef, serializer);
}
return ResultList.Create<IContentEntity>(contentCount.Result, contentItems.Result);
return ResultList.Create<IContentEntity>(contentItems.Count, contentItems);
}
public async Task<IContentEntity> FindContentAsync(ISchemaEntity schema, Guid id, Status[] status, bool includeDraft)

1
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs

@ -51,7 +51,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
[BsonRequired]
[BsonElement("ss")]
[BsonRepresentation(BsonType.String)]
public Status Status { get; set; }
[BsonIgnoreIfNull]

11
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs

@ -36,6 +36,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
private readonly string typeContentDeleted;
private readonly MongoContentCollection contents;
static MongoContentRepository()
{
StatusSerializer.Register();
}
public MongoContentRepository(IMongoDatabase database, IAppProvider appProvider, IJsonSerializer serializer, ITextIndexer indexer, TypeNameRegistry typeNameRegistry)
{
Guard.NotNull(appProvider, nameof(appProvider));
@ -64,7 +69,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{
Guard.NotNull(app, nameof(app));
Guard.NotNull(schema, nameof(schema));
Guard.NotNull(status, nameof(status));
Guard.NotNull(query, nameof(query));
using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByQuery"))
@ -83,9 +87,8 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[] status, HashSet<Guid> ids, bool includeDraft = true)
{
Guard.NotNull(app, nameof(app));
Guard.NotNull(schema, nameof(schema));
Guard.NotNull(status, nameof(status));
Guard.NotNull(ids, nameof(ids));
Guard.NotNull(schema, nameof(schema));
using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByIds"))
{
@ -96,7 +99,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
public async Task<List<(IContentEntity Content, ISchemaEntity Schema)>> QueryAsync(IAppEntity app, Status[] status, HashSet<Guid> ids, bool includeDraft = true)
{
Guard.NotNull(app, nameof(app));
Guard.NotNull(status, nameof(status));
Guard.NotNull(ids, nameof(ids));
using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByIdsWithoutSchema"))
@ -109,7 +111,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{
Guard.NotNull(app, nameof(app));
Guard.NotNull(schema, nameof(schema));
Guard.NotNull(status, nameof(status));
using (Profiler.TraceMethod<MongoContentRepository>())
{

39
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/StatusSerializer.cs

@ -0,0 +1,39 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading;
using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Serializers;
using Squidex.Domain.Apps.Core.Contents;
namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{
public sealed class StatusSerializer : SerializerBase<Status>
{
private static volatile int isRegistered;
public static void Register()
{
if (Interlocked.Increment(ref isRegistered) == 1)
{
BsonSerializer.RegisterSerializer(new StatusSerializer());
}
}
public override Status Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args)
{
var value = context.Reader.ReadString();
return new Status(value);
}
public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, Status value)
{
context.Writer.WriteString(value.Name);
}
}
}

6
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FilterFactory.cs

@ -162,7 +162,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors
}
filters.Add(Filter.Ne(x => x.IsDeleted, true));
filters.Add(Filter.In(x => x.Status, status));
if (status != null)
{
filters.Add(Filter.In(x => x.Status, status));
}
if (ids != null && ids.Count > 0)
{

20
src/Squidex.Domain.Apps.Entities/Apps/AppExtensions.cs

@ -0,0 +1,20 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Apps
{
public static class AppExtensions
{
public static NamedId<Guid> NamedId(this IAppEntity app)
{
return new NamedId<Guid>(app.Id, app.Name);
}
}
}

62
src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs

@ -60,11 +60,13 @@ namespace Squidex.Domain.Apps.Entities.Apps
switch (command)
{
case CreateApp createApp:
return CreateAsync(createApp, c =>
return CreateReturn(createApp, c =>
{
GuardApp.CanCreate(c);
Create(c);
return Snapshot;
});
case AssignContributor assignContributor:
@ -74,111 +76,137 @@ namespace Squidex.Domain.Apps.Entities.Apps
AssignContributor(c, !Snapshot.Contributors.ContainsKey(assignContributor.ContributorId));
return EntityCreatedResult.Create(c.ContributorId, Version);
return Snapshot;
});
case RemoveContributor removeContributor:
return UpdateAsync(removeContributor, c =>
return UpdateReturn(removeContributor, c =>
{
GuardAppContributors.CanRemove(Snapshot.Contributors, c);
RemoveContributor(c);
return Snapshot;
});
case AttachClient attachClient:
return UpdateAsync(attachClient, c =>
return UpdateReturn(attachClient, c =>
{
GuardAppClients.CanAttach(Snapshot.Clients, c);
AttachClient(c);
return Snapshot;
});
case UpdateClient updateClient:
return UpdateAsync(updateClient, c =>
return UpdateReturn(updateClient, c =>
{
GuardAppClients.CanUpdate(Snapshot.Clients, c, Snapshot.Roles);
UpdateClient(c);
return Snapshot;
});
case RevokeClient revokeClient:
return UpdateAsync(revokeClient, c =>
return UpdateReturn(revokeClient, c =>
{
GuardAppClients.CanRevoke(Snapshot.Clients, c);
RevokeClient(c);
return Snapshot;
});
case AddLanguage addLanguage:
return UpdateAsync(addLanguage, c =>
return UpdateReturn(addLanguage, c =>
{
GuardAppLanguages.CanAdd(Snapshot.LanguagesConfig, c);
AddLanguage(c);
return Snapshot;
});
case RemoveLanguage removeLanguage:
return UpdateAsync(removeLanguage, c =>
return UpdateReturn(removeLanguage, c =>
{
GuardAppLanguages.CanRemove(Snapshot.LanguagesConfig, c);
RemoveLanguage(c);
return Snapshot;
});
case UpdateLanguage updateLanguage:
return UpdateAsync(updateLanguage, c =>
return UpdateReturn(updateLanguage, c =>
{
GuardAppLanguages.CanUpdate(Snapshot.LanguagesConfig, c);
UpdateLanguage(c);
return Snapshot;
});
case AddRole addRole:
return UpdateAsync(addRole, c =>
return UpdateReturn(addRole, c =>
{
GuardAppRoles.CanAdd(Snapshot.Roles, c);
AddRole(c);
return Snapshot;
});
case DeleteRole deleteRole:
return UpdateAsync(deleteRole, c =>
return UpdateReturn(deleteRole, c =>
{
GuardAppRoles.CanDelete(Snapshot.Roles, c, Snapshot.Contributors, Snapshot.Clients);
DeleteRole(c);
return Snapshot;
});
case UpdateRole updateRole:
return UpdateAsync(updateRole, c =>
return UpdateReturn(updateRole, c =>
{
GuardAppRoles.CanUpdate(Snapshot.Roles, c);
UpdateRole(c);
return Snapshot;
});
case AddPattern addPattern:
return UpdateAsync(addPattern, c =>
return UpdateReturn(addPattern, c =>
{
GuardAppPatterns.CanAdd(Snapshot.Patterns, c);
AddPattern(c);
return Snapshot;
});
case DeletePattern deletePattern:
return UpdateAsync(deletePattern, c =>
return UpdateReturn(deletePattern, c =>
{
GuardAppPatterns.CanDelete(Snapshot.Patterns, c);
DeletePattern(c);
return Snapshot;
});
case UpdatePattern updatePattern:
return UpdateAsync(updatePattern, c =>
return UpdateReturn(updatePattern, c =>
{
GuardAppPatterns.CanUpdate(Snapshot.Patterns, c);
UpdatePattern(c);
return Snapshot;
});
case ChangePlan changePlan:
@ -194,7 +222,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
}
else
{
var result = await appPlansBillingManager.ChangePlanAsync(c.Actor.Identifier, Snapshot.Id, Snapshot.Name, c.PlanId);
var result = await appPlansBillingManager.ChangePlanAsync(c.Actor.Identifier, Snapshot.NamedId(), c.PlanId);
switch (result)
{
@ -213,7 +241,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
case ArchiveApp archiveApp:
return UpdateAsync(archiveApp, async c =>
{
await appPlansBillingManager.ChangePlanAsync(c.Actor.Identifier, Snapshot.Id, Snapshot.Name, null);
await appPlansBillingManager.ChangePlanAsync(c.Actor.Identifier, Snapshot.NamedId(), null);
ArchiveApp(c);
});

14
src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByUserIndexCommandMiddleware.cs

@ -35,7 +35,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
await Index(GetUserId(createApp)).AddAppAsync(createApp.AppId);
break;
case AssignContributor assignContributor:
await Index(GetUserId(context)).AddAppAsync(assignContributor.AppId);
await Index(GetUserId(assignContributor)).AddAppAsync(assignContributor.AppId);
break;
case RemoveContributor removeContributor:
await Index(GetUserId(removeContributor)).RemoveAppAsync(removeContributor.AppId);
@ -57,19 +57,19 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
await next();
}
private static string GetUserId(RemoveContributor removeContributor)
private static string GetUserId(CreateApp createApp)
{
return removeContributor.ContributorId;
return createApp.Actor.Identifier;
}
private static string GetUserId(CreateApp createApp)
private static string GetUserId(AssignContributor assignContributor)
{
return createApp.Actor.Identifier;
return assignContributor.ContributorId;
}
private static string GetUserId(CommandContext context)
private static string GetUserId(RemoveContributor removeContributor)
{
return context.Result<EntityCreatedResult<string>>().IdOrValue;
return removeContributor.ContributorId;
}
private IAppsByUserIndex Index(string id)

4
src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs

@ -35,9 +35,9 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation
await next();
if (assignContributor.IsCreated && context.PlainResult is EntityCreatedResult<string> id)
if (assignContributor.IsCreated && context.PlainResult is IAppEntity app)
{
context.Complete(new InvitedResult { Id = id });
context.Complete(new InvitedResult { App = app });
}
return;

4
src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitedResult.cs

@ -5,12 +5,10 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Apps.Invitation
{
public sealed class InvitedResult
{
public EntityCreatedResult<string> Id { get; set; }
public IAppEntity App { get; set; }
}
}

2
src/Squidex.Domain.Apps.Entities/Apps/RoleExtensions.cs

@ -55,7 +55,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
{
return id;
}
}));
}).Where(x => x != "common"));
}
}
}

3
src/Squidex.Domain.Apps.Entities/Apps/Services/IAppPlanBillingManager.cs

@ -7,6 +7,7 @@
using System;
using System.Threading.Tasks;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Apps.Services
{
@ -14,7 +15,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Services
{
bool HasPortal { get; }
Task<IChangePlanResult> ChangePlanAsync(string userId, Guid appId, string appName, string planId);
Task<IChangePlanResult> ChangePlanAsync(string userId, NamedId<Guid> appId, string planId);
Task<string> GetPortalLinkAsync(string userId);
}

3
src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/NoopAppPlanBillingManager.cs

@ -7,6 +7,7 @@
using System;
using System.Threading.Tasks;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Apps.Services.Implementations
{
@ -17,7 +18,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Services.Implementations
get { return false; }
}
public Task<IChangePlanResult> ChangePlanAsync(string userId, Guid appId, string appName, string planId)
public Task<IChangePlanResult> ChangePlanAsync(string userId, NamedId<Guid> appId, string planId)
{
return Task.FromResult<IChangePlanResult>(new PlanResetResult());
}

80
src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs

@ -10,6 +10,7 @@ using System.Collections.Generic;
using System.Security.Cryptography;
using System.Threading.Tasks;
using Orleans;
using Squidex.Domain.Apps.Core.Tags;
using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Domain.Apps.Entities.Tags;
using Squidex.Infrastructure;
@ -24,25 +25,28 @@ namespace Squidex.Domain.Apps.Entities.Assets
private readonly IAssetQueryService assetQuery;
private readonly IAssetThumbnailGenerator assetThumbnailGenerator;
private readonly IEnumerable<ITagGenerator<CreateAsset>> tagGenerators;
private readonly ITagService tagService;
public AssetCommandMiddleware(
IGrainFactory grainFactory,
IAssetQueryService assetQuery,
IAssetStore assetStore,
IAssetThumbnailGenerator assetThumbnailGenerator,
IEnumerable<ITagGenerator<CreateAsset>> tagGenerators)
IEnumerable<ITagGenerator<CreateAsset>> tagGenerators,
ITagService tagService)
: base(grainFactory)
{
Guard.NotNull(assetStore, nameof(assetStore));
Guard.NotNull(assetQuery, nameof(assetQuery));
Guard.NotNull(assetThumbnailGenerator, nameof(assetThumbnailGenerator));
Guard.NotNull(tagGenerators, nameof(tagGenerators));
Guard.NotNull(tagService, nameof(tagService));
this.assetStore = assetStore;
this.assetQuery = assetQuery;
this.assetThumbnailGenerator = assetThumbnailGenerator;
this.tagGenerators = tagGenerators;
this.tagService = tagService;
}
public override async Task HandleAsync(CommandContext context, Func<Task> next)
@ -56,9 +60,8 @@ namespace Squidex.Domain.Apps.Entities.Assets
createAsset.Tags = new HashSet<string>();
}
createAsset.ImageInfo = await assetThumbnailGenerator.GetImageInfoAsync(createAsset.File.OpenRead());
createAsset.FileHash = await UploadAsync(context, createAsset.File);
await EnrichWithImageInfosAsync(createAsset);
await EnrichWithHashAndUploadAsync(createAsset, context);
try
{
@ -70,13 +73,9 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
if (IsDuplicate(createAsset, existing))
{
result = new AssetCreatedResult(
existing.Id,
existing.Tags,
existing.Version,
existing.FileVersion,
existing.FileHash,
true);
var denormalizedTags = await tagService.DenormalizeTagsAsync(createAsset.AppId.Id, TagGroups.Assets, existing.Tags);
result = new AssetCreatedResult(existing, true, new HashSet<string>(denormalizedTags.Values));
}
break;
@ -89,17 +88,11 @@ namespace Squidex.Domain.Apps.Entities.Assets
tagGenerator.GenerateTags(createAsset, createAsset.Tags);
}
var commandResult = (AssetSavedResult)await ExecuteCommandAsync(createAsset);
var asset = (IAssetEntity)await ExecuteCommandAsync(createAsset);
result = new AssetCreatedResult(
createAsset.AssetId,
createAsset.Tags,
commandResult.Version,
commandResult.FileVersion,
commandResult.FileHash,
false);
result = new AssetCreatedResult(asset, false, createAsset.Tags);
await assetStore.CopyAsync(context.ContextId.ToString(), createAsset.AssetId.ToString(), result.FileVersion, null);
await assetStore.CopyAsync(context.ContextId.ToString(), createAsset.AssetId.ToString(), asset.FileVersion, null);
}
context.Complete(result);
@ -114,16 +107,16 @@ namespace Squidex.Domain.Apps.Entities.Assets
case UpdateAsset updateAsset:
{
updateAsset.ImageInfo = await assetThumbnailGenerator.GetImageInfoAsync(updateAsset.File.OpenRead());
await EnrichWithImageInfosAsync(updateAsset);
await EnrichWithHashAndUploadAsync(updateAsset, context);
updateAsset.FileHash = await UploadAsync(context, updateAsset.File);
try
{
var result = (AssetSavedResult)await ExecuteCommandAsync(updateAsset);
var result = (AssetResult)await ExecuteAndAdjustTagsAsync(updateAsset);
context.Complete(result);
await assetStore.CopyAsync(context.ContextId.ToString(), updateAsset.AssetId.ToString(), result.FileVersion, null);
await assetStore.CopyAsync(context.ContextId.ToString(), updateAsset.AssetId.ToString(), result.Asset.FileVersion, null);
}
finally
{
@ -133,29 +126,54 @@ namespace Squidex.Domain.Apps.Entities.Assets
break;
}
case AssetCommand command:
{
var result = await ExecuteAndAdjustTagsAsync(command);
context.Complete(result);
break;
}
default:
await base.HandleAsync(context, next);
break;
}
}
private async Task<object> ExecuteAndAdjustTagsAsync(AssetCommand command)
{
var result = await ExecuteCommandAsync(command);
if (result is IAssetEntity asset)
{
var denormalizedTags = await tagService.DenormalizeTagsAsync(asset.AppId.Id, TagGroups.Assets, asset.Tags);
return new AssetResult(asset, new HashSet<string>(denormalizedTags.Values));
}
return result;
}
private static bool IsDuplicate(CreateAsset createAsset, IAssetEntity asset)
{
return asset != null && asset.FileName == createAsset.File.FileName && asset.FileSize == createAsset.File.FileSize;
}
private async Task<string> UploadAsync(CommandContext context, AssetFile file)
private async Task EnrichWithImageInfosAsync(UploadAssetCommand command)
{
string hash;
command.ImageInfo = await assetThumbnailGenerator.GetImageInfoAsync(command.File.OpenRead());
}
using (var hashStream = new HasherStream(file.OpenRead(), HashAlgorithmName.SHA256))
private async Task EnrichWithHashAndUploadAsync(UploadAssetCommand command, CommandContext context)
{
using (var hashStream = new HasherStream(command.File.OpenRead(), HashAlgorithmName.SHA256))
{
await assetStore.UploadAsync(context.ContextId.ToString(), hashStream);
hash = $"{hashStream.GetHashStringAndReset()}{file.FileName}{file.FileSize}".Sha256Base64();
command.FileHash = $"{hashStream.GetHashStringAndReset()}{command.File.FileName}{command.File.FileSize}".Sha256Base64();
}
return hash;
}
}
}

19
src/Squidex.Domain.Apps.Entities/Assets/AssetCreatedResult.cs

@ -5,30 +5,17 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Assets
{
public sealed class AssetCreatedResult : EntityCreatedResult<Guid>
public sealed class AssetCreatedResult : AssetResult
{
public HashSet<string> Tags { get; }
public long FileVersion { get; }
public string FileHash { get; }
public bool IsDuplicate { get; }
public AssetCreatedResult(Guid id, HashSet<string> tags, long version, long fileVersion, string fileHash, bool isDuplicate)
: base(id, version)
public AssetCreatedResult(IAssetEntity asset, bool isDuplicate, HashSet<string> tags)
: base(asset, tags)
{
Tags = tags;
FileVersion = fileVersion;
FileHash = fileHash;
IsDuplicate = isDuplicate;
}
}

26
src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs

@ -51,16 +51,27 @@ namespace Squidex.Domain.Apps.Entities.Assets
Create(c, tagIds);
return new AssetSavedResult(Version, Snapshot.FileVersion, Snapshot.FileHash);
return Snapshot;
});
case UpdateAsset updateRule:
return UpdateAsync(updateRule, c =>
return UpdateReturn(updateRule, c =>
{
GuardAsset.CanUpdate(c);
Update(c);
return new AssetSavedResult(Version, Snapshot.FileVersion, Snapshot.FileHash);
return Snapshot;
});
case AnnotateAsset annotateAsset:
return UpdateReturnAsync(annotateAsset, async c =>
{
GuardAsset.CanAnnotate(c, Snapshot.FileName, Snapshot.Slug);
var tagIds = await NormalizeTagsAsync(Snapshot.AppId.Id, c.Tags);
Annotate(c, tagIds);
return Snapshot;
});
case DeleteAsset deleteAsset:
return UpdateAsync(deleteAsset, async c =>
@ -71,15 +82,6 @@ namespace Squidex.Domain.Apps.Entities.Assets
Delete(c);
});
case AnnotateAsset annotateAsset:
return UpdateAsync(annotateAsset, async c =>
{
GuardAsset.CanAnnotate(c, Snapshot.FileName, Snapshot.Slug);
var tagIds = await NormalizeTagsAsync(Snapshot.AppId.Id, c.Tags);
Annotate(c, tagIds);
});
default:
throw new NotSupportedException();
}

25
src/Squidex.Domain.Apps.Entities/Assets/AssetResult.cs

@ -0,0 +1,25 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
namespace Squidex.Domain.Apps.Entities.Assets
{
public class AssetResult
{
public IAssetEntity Asset { get; }
public HashSet<string> Tags { get; }
public AssetResult(IAssetEntity asset, HashSet<string> tags)
{
Asset = asset;
Tags = tags;
}
}
}

25
src/Squidex.Domain.Apps.Entities/Assets/AssetSavedResult.cs

@ -1,25 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Assets
{
public class AssetSavedResult : EntitySavedResult
{
public long FileVersion { get; }
public string FileHash { get; }
public AssetSavedResult(long version, long fileVersion, string fileHash)
: base(version)
{
FileVersion = fileVersion;
FileHash = fileHash;
}
}
}

9
src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs

@ -8,22 +8,15 @@
using System;
using System.Collections.Generic;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Assets;
namespace Squidex.Domain.Apps.Entities.Assets.Commands
{
public sealed class CreateAsset : AssetCommand, IAppCommand
public sealed class CreateAsset : UploadAssetCommand, IAppCommand
{
public NamedId<Guid> AppId { get; set; }
public AssetFile File { get; set; }
public ImageInfo ImageInfo { get; set; }
public HashSet<string> Tags { get; set; }
public string FileHash { get; set; }
public CreateAsset()
{
AssetId = Guid.NewGuid();

9
src/Squidex.Domain.Apps.Entities/Assets/Commands/UpdateAsset.cs

@ -5,16 +5,9 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure.Assets;
namespace Squidex.Domain.Apps.Entities.Assets.Commands
{
public sealed class UpdateAsset : AssetCommand
public sealed class UpdateAsset : UploadAssetCommand
{
public AssetFile File { get; set; }
public ImageInfo ImageInfo { get; set; }
public string FileHash { get; set; }
}
}

20
src/Squidex.Domain.Apps.Entities/Assets/Commands/UploadAssetCommand.cs

@ -0,0 +1,20 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure.Assets;
namespace Squidex.Domain.Apps.Entities.Assets.Commands
{
public abstract class UploadAssetCommand : AssetCommand
{
public AssetFile File { get; set; }
public ImageInfo ImageInfo { get; set; }
public string FileHash { get; set; }
}
}

35
src/Squidex.Domain.Apps.Entities/Assets/Edm/EdmAssetModel.cs

@ -18,20 +18,27 @@ namespace Squidex.Domain.Apps.Entities.Assets.Edm
{
var entityType = new EdmEntityType("Squidex", "Asset");
entityType.AddStructuralProperty(nameof(IAssetEntity.Id).ToCamelCase(), EdmPrimitiveTypeKind.String);
entityType.AddStructuralProperty(nameof(IAssetEntity.Created).ToCamelCase(), EdmPrimitiveTypeKind.DateTimeOffset);
entityType.AddStructuralProperty(nameof(IAssetEntity.CreatedBy).ToCamelCase(), EdmPrimitiveTypeKind.String);
entityType.AddStructuralProperty(nameof(IAssetEntity.LastModified).ToCamelCase(), EdmPrimitiveTypeKind.DateTimeOffset);
entityType.AddStructuralProperty(nameof(IAssetEntity.LastModifiedBy).ToCamelCase(), EdmPrimitiveTypeKind.String);
entityType.AddStructuralProperty(nameof(IAssetEntity.Version).ToCamelCase(), EdmPrimitiveTypeKind.Int64);
entityType.AddStructuralProperty(nameof(IAssetEntity.FileName).ToCamelCase(), EdmPrimitiveTypeKind.String);
entityType.AddStructuralProperty(nameof(IAssetEntity.FileSize).ToCamelCase(), EdmPrimitiveTypeKind.Int64);
entityType.AddStructuralProperty(nameof(IAssetEntity.FileVersion).ToCamelCase(), EdmPrimitiveTypeKind.Int64);
entityType.AddStructuralProperty(nameof(IAssetEntity.IsImage).ToCamelCase(), EdmPrimitiveTypeKind.Boolean);
entityType.AddStructuralProperty(nameof(IAssetEntity.MimeType).ToCamelCase(), EdmPrimitiveTypeKind.String);
entityType.AddStructuralProperty(nameof(IAssetEntity.PixelHeight).ToCamelCase(), EdmPrimitiveTypeKind.Int32);
entityType.AddStructuralProperty(nameof(IAssetEntity.PixelWidth).ToCamelCase(), EdmPrimitiveTypeKind.Int32);
entityType.AddStructuralProperty(nameof(IAssetEntity.Tags).ToCamelCase(), EdmPrimitiveTypeKind.String);
void AddProperty(string name, EdmPrimitiveTypeKind type)
{
entityType.AddStructuralProperty(name.ToCamelCase(), type);
}
AddProperty(nameof(IAssetEntity.Id), EdmPrimitiveTypeKind.String);
AddProperty(nameof(IAssetEntity.Created), EdmPrimitiveTypeKind.DateTimeOffset);
AddProperty(nameof(IAssetEntity.CreatedBy), EdmPrimitiveTypeKind.String);
AddProperty(nameof(IAssetEntity.LastModified), EdmPrimitiveTypeKind.DateTimeOffset);
AddProperty(nameof(IAssetEntity.LastModifiedBy), EdmPrimitiveTypeKind.String);
AddProperty(nameof(IAssetEntity.Version), EdmPrimitiveTypeKind.Int64);
AddProperty(nameof(IAssetEntity.FileName), EdmPrimitiveTypeKind.String);
AddProperty(nameof(IAssetEntity.FileHash), EdmPrimitiveTypeKind.String);
AddProperty(nameof(IAssetEntity.FileSize), EdmPrimitiveTypeKind.Int64);
AddProperty(nameof(IAssetEntity.FileVersion), EdmPrimitiveTypeKind.Int64);
AddProperty(nameof(IAssetEntity.IsImage), EdmPrimitiveTypeKind.Boolean);
AddProperty(nameof(IAssetEntity.MimeType), EdmPrimitiveTypeKind.String);
AddProperty(nameof(IAssetEntity.PixelHeight), EdmPrimitiveTypeKind.Int32);
AddProperty(nameof(IAssetEntity.PixelWidth), EdmPrimitiveTypeKind.Int32);
AddProperty(nameof(IAssetEntity.Slug), EdmPrimitiveTypeKind.String);
AddProperty(nameof(IAssetEntity.Tags), EdmPrimitiveTypeKind.String);
var container = new EdmEntityContainer("Squidex", "Container");

6
src/Squidex.Domain.Apps.Entities/Comments/CommentsGrain.cs

@ -73,7 +73,7 @@ namespace Squidex.Domain.Apps.Entities.Comments
switch (command)
{
case CreateComment createComment:
return UpsertAsync(createComment, c =>
return UpsertReturn(createComment, c =>
{
GuardComments.CanCreate(c);
@ -83,7 +83,7 @@ namespace Squidex.Domain.Apps.Entities.Comments
});
case UpdateComment updateComment:
return UpsertAsync(updateComment, c =>
return Upsert(updateComment, c =>
{
GuardComments.CanUpdate(events, c);
@ -91,7 +91,7 @@ namespace Squidex.Domain.Apps.Entities.Comments
});
case DeleteComment deleteComment:
return UpsertAsync(deleteComment, c =>
return Upsert(deleteComment, c =>
{
GuardComments.CanDelete(events, c);

7
src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs

@ -69,11 +69,8 @@ namespace Squidex.Domain.Apps.Entities.Contents
case StatusChange.Unpublished:
result.Type = EnrichedContentEventType.Unpublished;
break;
case StatusChange.Archived:
result.Type = EnrichedContentEventType.Archived;
break;
case StatusChange.Restored:
result.Type = EnrichedContentEventType.Restored;
default:
result.Type = EnrichedContentEventType.StatusChanged;
break;
}

23
src/Squidex.Domain.Apps.Entities/Contents/ContentDataChangedResult.cs

@ -1,23 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Contents
{
public sealed class ContentDataChangedResult : EntitySavedResult
{
public NamedContentData Data { get; }
public ContentDataChangedResult(NamedContentData data, long version)
: base(version)
{
Data = data;
}
}
}

21
src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs

@ -8,9 +8,7 @@
using System;
using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Contents.Commands;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Contents
{
@ -41,24 +39,5 @@ namespace Squidex.Domain.Apps.Entities.Contents
public Status Status { get; set; }
public bool IsPending { get; set; }
public static ContentEntity Create(CreateContent command, EntityCreatedResult<NamedContentData> result)
{
var now = SystemClock.Instance.GetCurrentInstant();
var response = new ContentEntity
{
Id = command.ContentId,
Data = result.IdOrValue,
Version = result.Version,
Created = now,
CreatedBy = command.Actor,
LastModified = now,
LastModifiedBy = command.Actor,
Status = command.Publish ? Status.Published : Status.Draft
};
return response;
}
}
}

64
src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs

@ -32,6 +32,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
private readonly IAssetRepository assetRepository;
private readonly IContentRepository contentRepository;
private readonly IScriptEngine scriptEngine;
private readonly IContentWorkflow contentWorkflow;
public ContentGrain(
IStore<Guid> store,
@ -39,17 +40,20 @@ namespace Squidex.Domain.Apps.Entities.Contents
IAppProvider appProvider,
IAssetRepository assetRepository,
IScriptEngine scriptEngine,
IContentWorkflow contentWorkflow,
IContentRepository contentRepository)
: base(store, log)
{
Guard.NotNull(appProvider, nameof(appProvider));
Guard.NotNull(scriptEngine, nameof(scriptEngine));
Guard.NotNull(assetRepository, nameof(assetRepository));
Guard.NotNull(contentWorkflow, nameof(contentWorkflow));
Guard.NotNull(contentRepository, nameof(contentRepository));
this.appProvider = appProvider;
this.scriptEngine = scriptEngine;
this.assetRepository = assetRepository;
this.contentWorkflow = contentWorkflow;
this.contentRepository = contentRepository;
}
@ -79,35 +83,37 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ctx.ExecuteScriptAsync(s => s.Change, "Published", c, c.Data);
}
Create(c);
var status = await contentWorkflow.GetInitialStatusAsync(ctx.Schema);
return EntityCreatedResult.Create(c.Data, Version);
Create(c, status);
return Snapshot;
});
case UpdateContent updateContent:
return UpdateReturnAsync(updateContent, c =>
return UpdateReturnAsync(updateContent, async c =>
{
GuardContent.CanUpdate(c);
await GuardContent.CanUpdate(Snapshot, contentWorkflow, c);
return UpdateAsync(c, x => c.Data, false);
return await UpdateAsync(c, x => c.Data, false);
});
case PatchContent patchContent:
return UpdateReturnAsync(patchContent, c =>
return UpdateReturnAsync(patchContent, async c =>
{
GuardContent.CanPatch(c);
await GuardContent.CanPatch(Snapshot, contentWorkflow, c);
return UpdateAsync(c, c.Data.MergeInto, true);
return await UpdateAsync(c, c.Data.MergeInto, true);
});
case ChangeContentStatus changeContentStatus:
return UpdateAsync(changeContentStatus, async c =>
return UpdateReturnAsync(changeContentStatus, async c =>
{
try
{
var ctx = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, Snapshot.Id, () => "Failed to change content.");
GuardContent.CanChangeContentStatus(ctx.Schema, Snapshot.IsPending, Snapshot.Status, c);
await GuardContent.CanChangeStatus(ctx.Schema, Snapshot, contentWorkflow, c);
if (c.DueTime.HasValue)
{
@ -127,17 +133,13 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
reason = StatusChange.Published;
}
else if (c.Status == Status.Archived)
{
reason = StatusChange.Archived;
}
else if (Snapshot.Status == Status.Published)
{
reason = StatusChange.Unpublished;
}
else
{
reason = StatusChange.Restored;
reason = StatusChange.Change;
}
await ctx.ExecuteScriptAsync(s => s.Change, reason, c, Snapshot.Data);
@ -157,6 +159,18 @@ namespace Squidex.Domain.Apps.Entities.Contents
throw;
}
}
return Snapshot;
});
case DiscardChanges discardChanges:
return UpdateReturn(discardChanges, c =>
{
GuardContent.CanDiscardChanges(Snapshot.IsPending, c);
DiscardChanges(c);
return Snapshot;
});
case DeleteContent deleteContent:
@ -171,14 +185,6 @@ namespace Squidex.Domain.Apps.Entities.Contents
Delete(c);
});
case DiscardChanges discardChanges:
return UpdateAsync(discardChanges, c =>
{
GuardContent.CanDiscardChanges(Snapshot.IsPending, c);
DiscardChanges(c);
});
default:
throw new NotSupportedException();
}
@ -220,16 +226,16 @@ namespace Squidex.Domain.Apps.Entities.Contents
}
}
return new ContentDataChangedResult(newData, Version);
return Snapshot;
}
public void Create(CreateContent command)
public void Create(CreateContent command, Status status)
{
RaiseEvent(SimpleMapper.Map(command, new ContentCreated()));
RaiseEvent(SimpleMapper.Map(command, new ContentCreated { Status = status }));
if (command.Publish)
{
RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged { Status = Status.Published, Change = StatusChange.Published }));
RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged { Status = Status.Published }));
}
}
@ -268,9 +274,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
RaiseEvent(SimpleMapper.Map(command, new ContentStatusScheduled { DueTime = command.DueTime.Value }));
}
public void ChangeStatus(ChangeContentStatus command, StatusChange reason)
public void ChangeStatus(ChangeContentStatus command, StatusChange change)
{
RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged { Change = reason }));
RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged { Change = change }));
}
private void RaiseEvent(SchemaEvent @event)

62
src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs

@ -33,10 +33,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
public sealed class ContentQueryService : IContentQueryService
{
private static readonly Status[] StatusAll = { Status.Archived, Status.Draft, Status.Published };
private static readonly Status[] StatusArchived = { Status.Archived };
private static readonly Status[] StatusPublishedOnly = { Status.Published };
private static readonly Status[] StatusPublishedDraft = { Status.Published, Status.Draft };
private readonly IContentRepository contentRepository;
private readonly IContentVersionLoader contentVersionLoader;
private readonly IAppProvider appProvider;
@ -76,16 +73,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
this.scriptEngine = scriptEngine;
}
public Task ThrowIfSchemaNotExistsAsync(QueryContext context, string schemaIdOrName)
{
return GetSchemaAsync(context, schemaIdOrName);
}
public async Task<IContentEntity> FindContentAsync(QueryContext context, string schemaIdOrName, Guid id, long version = -1)
{
Guard.NotNull(context, nameof(context));
var schema = await GetSchemaAsync(context, schemaIdOrName);
var schema = await GetSchemaOrThrowAsync(context, schemaIdOrName);
CheckPermission(context.User, schema);
@ -93,7 +85,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
var isVersioned = version > EtagVersion.Empty;
var status = GetFindStatus(context);
var status = GetStatus(context);
var content =
isVersioned ?
@ -113,13 +105,13 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
Guard.NotNull(context, nameof(context));
var schema = await GetSchemaAsync(context, schemaIdOrName);
var schema = await GetSchemaOrThrowAsync(context, schemaIdOrName);
CheckPermission(context.User, schema);
using (Profiler.TraceMethod<ContentQueryService>())
{
var status = GetQueryStatus(context);
var status = GetStatus(context);
IResultList<IContentEntity> contents;
@ -139,13 +131,13 @@ namespace Squidex.Domain.Apps.Entities.Contents
}
}
public async Task<IList<IContentEntity>> QueryAsync(QueryContext context, IReadOnlyList<Guid> ids)
public async Task<IReadOnlyList<IContentEntity>> QueryAsync(QueryContext context, IReadOnlyList<Guid> ids)
{
Guard.NotNull(context, nameof(context));
using (Profiler.TraceMethod<ContentQueryService>())
{
var status = GetQueryStatus(context);
var status = GetStatus(context);
List<IContentEntity> result;
@ -217,7 +209,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
result.Data = result.Data.ConvertName2Name(schema.SchemaDef, converters);
}
if (result.DataDraft != null && (context.ApiStatus == StatusForApi.PublishedDraft || context.IsFrontendClient))
if (result.DataDraft != null && (context.ApiStatus == StatusForApi.All || context.IsFrontendClient))
{
result.DataDraft = result.DataDraft.ConvertName2Name(schema.SchemaDef, converters);
}
@ -298,7 +290,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
}
}
public async Task<ISchemaEntity> GetSchemaAsync(QueryContext context, string schemaIdOrName)
public async Task<ISchemaEntity> GetSchemaOrThrowAsync(QueryContext context, string schemaIdOrName)
{
ISchemaEntity schema = null;
@ -340,15 +332,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
return permissions.Allows(permission);
}
private static Status[] GetFindStatus(QueryContext context)
private static Status[] GetStatus(QueryContext context)
{
if (context.IsFrontendClient)
{
return StatusAll;
}
else if (context.ApiStatus == StatusForApi.PublishedDraft)
if (context.IsFrontendClient || context.ApiStatus == StatusForApi.All)
{
return StatusPublishedDraft;
return null;
}
else
{
@ -356,32 +344,6 @@ namespace Squidex.Domain.Apps.Entities.Contents
}
}
private static Status[] GetQueryStatus(QueryContext context)
{
if (context.IsFrontendClient)
{
switch (context.FrontendStatus)
{
case StatusForFrontend.Archived:
return StatusArchived;
case StatusForFrontend.PublishedOnly:
return StatusPublishedOnly;
default:
return StatusPublishedDraft;
}
}
else
{
switch (context.ApiStatus)
{
case StatusForApi.PublishedDraft:
return StatusPublishedDraft;
default:
return StatusPublishedOnly;
}
}
}
private Task<List<(IContentEntity Content, ISchemaEntity Schema)>> QueryAsync(QueryContext context, IReadOnlyList<Guid> ids, Status[] status)
{
return contentRepository.QueryAsync(context.App, status, new HashSet<Guid>(ids), ShouldIncludeDraft(context));
@ -409,7 +371,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
private static bool ShouldIncludeDraft(QueryContext context)
{
return context.ApiStatus == StatusForApi.PublishedDraft || context.IsFrontendClient;
return context.ApiStatus == StatusForApi.All || context.IsFrontendClient;
}
}
}

54
src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs

@ -0,0 +1,54 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Domain.Apps.Entities.Contents
{
public sealed class DefaultContentWorkflow : IContentWorkflow
{
private static readonly Status[] All = { Status.Archived, Status.Draft, Status.Published };
private static readonly Dictionary<Status, Status[]> Flow = new Dictionary<Status, Status[]>
{
[Status.Draft] = new[] { Status.Archived, Status.Published },
[Status.Archived] = new[] { Status.Draft },
[Status.Published] = new[] { Status.Draft, Status.Archived }
};
public Task<Status> GetInitialStatusAsync(ISchemaEntity schema)
{
return Task.FromResult(Status.Draft);
}
public Task<bool> CanMoveToAsync(IContentEntity content, Status next)
{
return Task.FromResult(Flow.TryGetValue(content.Status, out var state) && state.Contains(next));
}
public Task<bool> CanUpdateAsync(IContentEntity content)
{
return Task.FromResult(content.Status != Status.Archived);
}
public Task<Status[]> GetNextsAsync(IContentEntity content)
{
return Task.FromResult(Flow.TryGetValue(content.Status, out var result) ? result : Array.Empty<Status>());
}
public Task<Status[]> GetAllAsync(ISchemaEntity schema)
{
return Task.FromResult(All);
}
}
}

41
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs

@ -8,6 +8,7 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using GraphQL;
using Microsoft.Extensions.Caching.Memory;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Assets;
@ -18,28 +19,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
public sealed class CachingGraphQLService : CachingProviderBase, IGraphQLService
{
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10);
private readonly IContentQueryService contentQuery;
private readonly IGraphQLUrlGenerator urlGenerator;
private readonly IAssetQueryService assetQuery;
private readonly IAppProvider appProvider;
public CachingGraphQLService(
IMemoryCache cache,
IAppProvider appProvider,
IAssetQueryService assetQuery,
IContentQueryService contentQuery,
IGraphQLUrlGenerator urlGenerator)
private readonly IDependencyResolver resolver;
public CachingGraphQLService(IMemoryCache cache, IDependencyResolver resolver)
: base(cache)
{
Guard.NotNull(appProvider, nameof(appProvider));
Guard.NotNull(assetQuery, nameof(assetQuery));
Guard.NotNull(contentQuery, nameof(contentQuery));
Guard.NotNull(urlGenerator, nameof(urlGenerator));
this.appProvider = appProvider;
this.assetQuery = assetQuery;
this.contentQuery = contentQuery;
this.urlGenerator = urlGenerator;
Guard.NotNull(resolver, nameof(resolver));
this.resolver = resolver;
}
public async Task<(bool HasError, object Response)> QueryAsync(QueryContext context, params GraphQLQuery[] queries)
@ -49,7 +36,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
var model = await GetModelAsync(context.App);
var ctx = new GraphQLExecutionContext(context, assetQuery, contentQuery, urlGenerator);
var ctx = new GraphQLExecutionContext(context, resolver);
var result = await Task.WhenAll(queries.Select(q => QueryInternalAsync(model, ctx, q)));
@ -63,14 +50,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
var model = await GetModelAsync(context.App);
var ctx = new GraphQLExecutionContext(context, assetQuery, contentQuery, urlGenerator);
var ctx = new GraphQLExecutionContext(context, resolver);
var result = await QueryInternalAsync(model, ctx, query);
return result;
}
private static async Task<(bool HasError, object Response)> QueryInternalAsync(GraphQLModel model, GraphQLExecutionContext ctx, GraphQLQuery query)
private async Task<(bool HasError, object Response)> QueryInternalAsync(GraphQLModel model, GraphQLExecutionContext ctx, GraphQLQuery query)
{
if (string.IsNullOrWhiteSpace(query.Query))
{
@ -97,9 +84,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
{
entry.AbsoluteExpirationRelativeToNow = CacheDuration;
var allSchemas = await appProvider.GetSchemasAsync(app.Id);
var allSchemas = await resolver.Resolve<IAppProvider>().GetSchemasAsync(app.Id);
return new GraphQLModel(app, allSchemas, contentQuery.DefaultPageSizeGraphQl, assetQuery.DefaultPageSizeGraphQl, urlGenerator);
return new GraphQLModel(app,
allSchemas,
resolver.Resolve<IContentQueryService>().DefaultPageSizeGraphQl,
resolver.Resolve<IAssetQueryService>().DefaultPageSizeGraphQl,
resolver.Resolve<IGraphQLUrlGenerator>());
});
}

99
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs

@ -7,37 +7,114 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using GraphQL;
using GraphQL.DataLoader;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types;
using Squidex.Infrastructure.Json.Objects;
using Squidex.Infrastructure.Log;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
{
public sealed class GraphQLExecutionContext : QueryExecutionContext
{
private static readonly List<IAssetEntity> EmptyAssets = new List<IAssetEntity>();
private static readonly List<IContentEntity> EmptyContents = new List<IContentEntity>();
private readonly IDataLoaderContextAccessor dataLoaderContextAccessor;
private readonly IDependencyResolver resolver;
public IGraphQLUrlGenerator UrlGenerator { get; }
public GraphQLExecutionContext(QueryContext context,
IAssetQueryService assetQuery,
IContentQueryService contentQuery,
IGraphQLUrlGenerator urlGenerator)
: base(context, assetQuery, contentQuery)
public ISemanticLog Log { get; }
public GraphQLExecutionContext(QueryContext context, IDependencyResolver resolver)
: base(context,
resolver.Resolve<IAssetQueryService>(),
resolver.Resolve<IContentQueryService>())
{
UrlGenerator = resolver.Resolve<IGraphQLUrlGenerator>();
dataLoaderContextAccessor = resolver.Resolve<IDataLoaderContextAccessor>();
this.resolver = resolver;
}
public void Setup(ExecutionOptions execution)
{
var loader = resolver.Resolve<DataLoaderDocumentListener>();
var logger = LoggingMiddleware.Create(resolver.Resolve<ISemanticLog>());
execution.Listeners.Add(loader);
execution.FieldMiddleware.Use(logger);
execution.UserContext = this;
}
public override Task<IAssetEntity> FindAssetAsync(Guid id)
{
UrlGenerator = urlGenerator;
var dataLoader = GetAssetsLoader();
return dataLoader.LoadAsync(id);
}
public override Task<IContentEntity> FindContentAsync(Guid schemaId, Guid id)
{
var dataLoader = GetContentsLoader(schemaId);
return dataLoader.LoadAsync(id);
}
public Task<IReadOnlyList<IAssetEntity>> GetReferencedAssetsAsync(IJsonValue value)
public async Task<IReadOnlyList<IAssetEntity>> GetReferencedAssetsAsync(IJsonValue value)
{
var ids = ParseIds(value);
return GetReferencedAssetsAsync(ids);
if (ids == null)
{
return EmptyAssets;
}
var dataLoader = GetAssetsLoader();
return await dataLoader.LoadManyAsync(ids);
}
public Task<IReadOnlyList<IContentEntity>> GetReferencedContentsAsync(Guid schemaId, IJsonValue value)
public async Task<IReadOnlyList<IContentEntity>> GetReferencedContentsAsync(Guid schemaId, IJsonValue value)
{
var ids = ParseIds(value);
return GetReferencedContentsAsync(schemaId, ids);
if (ids == null)
{
return EmptyContents;
}
var dataLoader = GetContentsLoader(schemaId);
return await dataLoader.LoadManyAsync(ids);
}
private IDataLoader<Guid, IAssetEntity> GetAssetsLoader()
{
return dataLoaderContextAccessor.Context.GetOrAddBatchLoader<Guid, IAssetEntity>("Assets",
async batch =>
{
var result = await GetReferencedAssetsAsync(new List<Guid>(batch));
return result.ToDictionary(x => x.Id);
});
}
private IDataLoader<Guid, IContentEntity> GetContentsLoader(Guid schemaId)
{
return dataLoaderContextAccessor.Context.GetOrAddBatchLoader<Guid, IContentEntity>($"Schema_{schemaId}",
async batch =>
{
var result = await GetReferencedContentsAsync(schemaId, new List<Guid>(batch));
return result.ToDictionary(x => x.Id);
});
}
private static ICollection<Guid> ParseIds(IJsonValue value)
@ -58,7 +135,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
}
catch
{
return new List<Guid>();
return null;
}
}
}

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

@ -135,9 +135,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
return partitionResolver(key);
}
public (IGraphType ResolveType, ValueResolver Resolver) GetGraphType(ISchemaEntity schema, IField field)
public (IGraphType ResolveType, ValueResolver Resolver) GetGraphType(ISchemaEntity schema, IField field, string fieldName)
{
return field.Accept(new QueryGraphTypeVisitor(schema, GetContentType, this, assetListType));
return field.Accept(new QueryGraphTypeVisitor(schema, GetContentType, this, assetListType, fieldName));
}
public IGraphType GetAssetType()
@ -175,15 +175,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
{
Guard.NotNull(context, nameof(context));
var inputs = query.Variables?.ToInputs();
var result = await new DocumentExecuter().ExecuteAsync(options =>
var result = await new DocumentExecuter().ExecuteAsync(execution =>
{
options.OperationName = query.OperationName;
options.UserContext = context;
options.Schema = graphQLSchema;
options.Inputs = inputs;
options.Query = query.Query;
context.Setup(execution);
execution.Schema = graphQLSchema;
execution.Inputs = query.Variables?.ToInputs();
execution.Query = query.Query;
}).ConfigureAwait(false);
return (result.Data, result.Errors?.Select(x => (object)new { x.Message, x.Locations }).ToArray());

2
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphModel.cs

@ -35,6 +35,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
IGraphType GetContentDataType(Guid schemaId);
(IGraphType ResolveType, ValueResolver Resolver) GetGraphType(ISchemaEntity schema, IField field);
(IGraphType ResolveType, ValueResolver Resolver) GetGraphType(ISchemaEntity schema, IField field, string fieldName);
}
}

42
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/LoggingMiddleware.cs

@ -0,0 +1,42 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using GraphQL.Instrumentation;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Log;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
{
public static class LoggingMiddleware
{
public static Func<FieldMiddlewareDelegate, FieldMiddlewareDelegate> Create(ISemanticLog log)
{
Guard.NotNull(log, nameof(log));
return new Func<FieldMiddlewareDelegate, FieldMiddlewareDelegate>(next =>
{
return async context =>
{
try
{
return await next(context);
}
catch (Exception ex)
{
log.LogWarning(ex, w => w
.WriteProperty("action", "reolveField")
.WriteProperty("status", "failed")
.WriteProperty("field", context.FieldName));
throw ex;
}
};
});
}
}
}

4
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AllTypes.cs

@ -28,8 +28,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
public static readonly IGraphType Float = new FloatGraphType();
public static readonly IGraphType Status = new EnumerationGraphType<Status>();
public static readonly IGraphType String = new StringGraphType();
public static readonly IGraphType Boolean = new BooleanGraphType();
@ -46,8 +44,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
public static readonly IGraphType NonNullBoolean = new NonNullGraphType(Boolean);
public static readonly IGraphType NonNullStatusType = new NonNullGraphType(Status);
public static readonly IGraphType NoopDate = new NoopGraphType(Date);
public static readonly IGraphType NoopJson = new NoopGraphType(Json);

59
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphType.cs

@ -25,18 +25,17 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
Name = $"{schemaType}DataDto";
foreach (var field in schema.SchemaDef.Fields.ForApi())
foreach (var (field, fieldName, typeName) in schema.SchemaDef.Fields.SafeFields())
{
var (resolvedType, valueResolver) = model.GetGraphType(schema, field);
var (resolvedType, valueResolver) = model.GetGraphType(schema, field, fieldName);
if (valueResolver != null)
{
var fieldType = field.TypeName();
var fieldName = field.DisplayName();
var displayName = field.DisplayName();
var fieldGraphType = new ObjectGraphType
{
Name = $"{schemaType}Data{fieldType}Dto"
Name = $"{schemaType}Data{typeName}Dto"
};
var partition = model.ResolvePartition(field.Partitioning);
@ -45,45 +44,51 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
{
var key = partitionItem.Key;
var partitionResolver = new FuncFieldResolver<object>(c =>
{
if (((ContentFieldData)c.Source).TryGetValue(key, out var value))
{
return valueResolver(value, c);
}
else
{
return null;
}
});
fieldGraphType.AddField(new FieldType
{
Name = key.EscapePartition(),
Resolver = partitionResolver,
Resolver = PartitionResolver(valueResolver, key),
ResolvedType = resolvedType,
Description = field.RawProperties.Hints
});
}
fieldGraphType.Description = $"The structure of the {fieldName} field of the {schemaName} content type.";
var fieldResolver = new FuncFieldResolver<NamedContentData, IReadOnlyDictionary<string, IJsonValue>>(c =>
{
return c.Source.GetOrDefault(field.Name);
});
fieldGraphType.Description = $"The structure of the {displayName} field of the {schemaName} content type.";
AddField(new FieldType
{
Name = field.Name.ToCamelCase(),
Resolver = fieldResolver,
Name = fieldName,
Resolver = FieldResolver(field),
ResolvedType = fieldGraphType,
Description = $"The {fieldName} field."
Description = $"The {displayName} field."
});
}
}
Description = $"The structure of the {schemaName} content type.";
}
private static FuncFieldResolver<object> PartitionResolver(ValueResolver valueResolver, string key)
{
return new FuncFieldResolver<object>(c =>
{
if (((ContentFieldData)c.Source).TryGetValue(key, out var value))
{
return valueResolver(value, c);
}
else
{
return null;
}
});
}
private static FuncFieldResolver<NamedContentData, IReadOnlyDictionary<string, IJsonValue>> FieldResolver(RootField field)
{
return new FuncFieldResolver<NamedContentData, IReadOnlyDictionary<string, IJsonValue>>(c =>
{
return c.Source.GetOrDefault(field.Name);
});
}
}
}

4
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs

@ -73,8 +73,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
AddField(new FieldType
{
Name = "status",
ResolvedType = AllTypes.NonNullStatusType,
Resolver = Resolve(x => x.Status),
ResolvedType = AllTypes.NonNullString,
Resolver = Resolve(x => x.Status.Name.ToUpperInvariant()),
Description = $"The the status of the {schemaName} content."
});

50
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Extensions.cs

@ -0,0 +1,50 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using GraphQL.DataLoader;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
{
public static class Extensions
{
public static IEnumerable<(T Field, string Name, string Type)> SafeFields<T>(this IEnumerable<T> fields) where T : IField
{
var allFields =
fields.ForApi()
.Select(f => (Field: f, Name: f.Name.ToCamelCase(), Type: f.TypeName())).GroupBy(x => x.Name)
.Select(g =>
{
return g.Select((f, i) => (f.Field, f.Name.SafeString(i), f.Type.SafeString(i)));
})
.SelectMany(x => x);
return allFields;
}
private static string SafeString(this string value, int index)
{
if (index > 0)
{
return value + (index + 1);
}
return value;
}
public static async Task<IReadOnlyList<T>> LoadManyAsync<TKey, T>(this IDataLoader<TKey, T> dataLoader, ICollection<TKey> keys) where T : class
{
var contents = await Task.WhenAll(keys.Select(x => dataLoader.LoadAsync(x)));
return contents.Where(x => x != null).ToList();
}
}
}

41
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/NestedGraphType.cs

@ -16,44 +16,49 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
{
public sealed class NestedGraphType : ObjectGraphType<JsonObject>
{
public NestedGraphType(IGraphModel model, ISchemaEntity schema, IArrayField field)
public NestedGraphType(IGraphModel model, ISchemaEntity schema, IArrayField field, string fieldName)
{
var schemaType = schema.TypeName();
var schemaName = schema.DisplayName();
var fieldName = field.DisplayName();
var fieldDisplayName = field.DisplayName();
Name = $"{schemaType}{fieldName}ChildDto";
foreach (var nestedField in field.Fields.ForApi())
foreach (var (nestedField, nestedName, _) in field.Fields.SafeFields())
{
var fieldInfo = model.GetGraphType(schema, nestedField);
var fieldInfo = model.GetGraphType(schema, nestedField, nestedName);
if (fieldInfo.ResolveType != null)
{
var resolver = new FuncFieldResolver<object>(c =>
{
if (((JsonObject)c.Source).TryGetValue(nestedField.Name, out var value))
{
return fieldInfo.Resolver(value, c);
}
else
{
return fieldInfo;
}
});
var resolver = ValueResolver(nestedField, fieldInfo);
AddField(new FieldType
{
Name = nestedField.Name.ToCamelCase(),
Name = nestedName,
Resolver = resolver,
ResolvedType = fieldInfo.ResolveType,
Description = $"The {fieldName}/{nestedField.DisplayName()} nested field."
Description = $"The {fieldDisplayName}/{nestedField.DisplayName()} nested field."
});
}
}
Description = $"The structure of the {schemaName}.{fieldName} nested schema.";
Description = $"The structure of the {schemaName}.{fieldDisplayName} nested schema.";
}
private static FuncFieldResolver<object> ValueResolver(NestedField nestedField, (IGraphType ResolveType, ValueResolver Resolver) fieldInfo)
{
return new FuncFieldResolver<object>(c =>
{
if (((JsonObject)c.Source).TryGetValue(nestedField.Name, out var value))
{
return fieldInfo.Resolver(value, c);
}
else
{
return fieldInfo;
}
});
}
}
}

6
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/QueryGraphTypeVisitor.cs

@ -22,13 +22,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
private readonly Func<Guid, IGraphType> schemaResolver;
private readonly IGraphModel model;
private readonly IGraphType assetListType;
private readonly string fieldName;
public QueryGraphTypeVisitor(ISchemaEntity schema, Func<Guid, IGraphType> schemaResolver, IGraphModel model, IGraphType assetListType)
public QueryGraphTypeVisitor(ISchemaEntity schema, Func<Guid, IGraphType> schemaResolver, IGraphModel model, IGraphType assetListType, string fieldName)
{
this.model = model;
this.assetListType = assetListType;
this.schema = schema;
this.schemaResolver = schemaResolver;
this.fieldName = fieldName;
}
public (IGraphType ResolveType, ValueResolver Resolver) Visit(IArrayField field)
@ -93,7 +95,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
private (IGraphType ResolveType, ValueResolver Resolver) ResolveNested(IArrayField field)
{
var schemaFieldType = new ListGraphType(new NonNullGraphType(new NestedGraphType(model, schema, field)));
var schemaFieldType = new ListGraphType(new NonNullGraphType(new NestedGraphType(model, schema, field, this.fieldName)));
return (schemaFieldType, NoopResolver);
}

35
src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Contents.Commands;
@ -30,7 +31,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
}
}
public static void CanUpdate(UpdateContent command)
public static async Task CanUpdate(IContentEntity content, IContentWorkflow contentWorkflow, UpdateContent command)
{
Guard.NotNull(command, nameof(command));
@ -38,9 +39,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
{
ValidateData(command, e);
});
await ValidateCanUpdate(content, contentWorkflow);
}
public static void CanPatch(PatchContent command)
public static async Task CanPatch(IContentEntity content, IContentWorkflow contentWorkflow, PatchContent command)
{
Guard.NotNull(command, nameof(command));
@ -48,6 +51,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
{
ValidateData(command, e);
});
await ValidateCanUpdate(content, contentWorkflow);
}
public static void CanDiscardChanges(bool isPending, DiscardChanges command)
@ -60,33 +65,29 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
}
}
public static void CanChangeContentStatus(ISchemaEntity schema, bool isPending, Status status, ChangeContentStatus command)
public static Task CanChangeStatus(ISchemaEntity schema, IContentEntity content, IContentWorkflow contentWorkflow, ChangeContentStatus command)
{
Guard.NotNull(command, nameof(command));
if (schema.SchemaDef.IsSingleton && command.Status != Status.Published)
{
throw new DomainException("Singleton content archived or unpublished.");
throw new DomainException("Singleton content cannot be changed.");
}
Validate.It(() => "Cannot change status.", e =>
return Validate.It(() => "Cannot change status.", async e =>
{
if (!StatusFlow.Exists(command.Status))
if (!await contentWorkflow.CanMoveToAsync(content, command.Status))
{
e(Not.Valid("Status"), nameof(command.Status));
}
else if (!StatusFlow.CanChange(status, command.Status))
{
if (status == command.Status && status == Status.Published)
if (content.Status == command.Status && content.Status == Status.Published)
{
if (!isPending)
if (!content.IsPending)
{
e("Content has no changes to publish.", nameof(command.Status));
}
}
else
{
e($"Cannot change status from {status} to {command.Status}.", nameof(command.Status));
e($"Cannot change status from {content.Status} to {command.Status}.", nameof(command.Status));
}
}
@ -114,5 +115,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
e(Not.Defined("Data"), nameof(command.Data));
}
}
private static async Task ValidateCanUpdate(IContentEntity content, IContentWorkflow contentWorkflow)
{
if (!await contentWorkflow.CanUpdateAsync(content))
{
throw new DomainException($"The workflow does not allow updates at status {content.Status}");
}
}
}
}

5
src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs

@ -8,6 +8,7 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents
@ -16,12 +17,12 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
int DefaultPageSizeGraphQl { get; }
Task<IList<IContentEntity>> QueryAsync(QueryContext context, IReadOnlyList<Guid> ids);
Task<IReadOnlyList<IContentEntity>> QueryAsync(QueryContext context, IReadOnlyList<Guid> ids);
Task<IResultList<IContentEntity>> QueryAsync(QueryContext context, string schemaIdOrName, Q query);
Task<IContentEntity> FindContentAsync(QueryContext context, string schemaIdOrName, Guid id, long version = EtagVersion.Any);
Task ThrowIfSchemaNotExistsAsync(QueryContext context, string schemaIdOrName);
Task<ISchemaEntity> GetSchemaOrThrowAsync(QueryContext context, string schemaIdOrName);
}
}

26
src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs

@ -0,0 +1,26 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Schemas;
namespace Squidex.Domain.Apps.Entities.Contents
{
public interface IContentWorkflow
{
Task<Status> GetInitialStatusAsync(ISchemaEntity schema);
Task<bool> CanMoveToAsync(IContentEntity content, Status next);
Task<bool> CanUpdateAsync(IContentEntity content);
Task<Status[]> GetNextsAsync(IContentEntity content);
Task<Status[]> GetAllAsync(ISchemaEntity schema);
}
}

12
src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs

@ -34,7 +34,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
this.context = context;
}
public async Task<IAssetEntity> FindAssetAsync(Guid id)
public virtual async Task<IAssetEntity> FindAssetAsync(Guid id)
{
var asset = cachedAssets.GetOrDefault(id);
@ -51,7 +51,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
return asset;
}
public async Task<IContentEntity> FindContentAsync(Guid schemaId, Guid id)
public virtual async Task<IContentEntity> FindContentAsync(Guid schemaId, Guid id)
{
var content = cachedContents.GetOrDefault(id);
@ -68,7 +68,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
return content;
}
public async Task<IResultList<IAssetEntity>> QueryAssetsAsync(string query)
public virtual async Task<IResultList<IAssetEntity>> QueryAssetsAsync(string query)
{
var assets = await assetQuery.QueryAsync(context, Q.Empty.WithODataQuery(query));
@ -80,7 +80,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
return assets;
}
public async Task<IResultList<IContentEntity>> QueryContentsAsync(string schemaIdOrName, string query)
public virtual async Task<IResultList<IContentEntity>> QueryContentsAsync(string schemaIdOrName, string query)
{
var result = await contentQuery.QueryAsync(context, schemaIdOrName, Q.Empty.WithODataQuery(query));
@ -92,7 +92,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
return result;
}
public async Task<IReadOnlyList<IAssetEntity>> GetReferencedAssetsAsync(ICollection<Guid> ids)
public virtual async Task<IReadOnlyList<IAssetEntity>> GetReferencedAssetsAsync(ICollection<Guid> ids)
{
Guard.NotNull(ids, nameof(ids));
@ -111,7 +111,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
return ids.Select(cachedAssets.GetOrDefault).Where(x => x != null).ToList();
}
public async Task<IReadOnlyList<IContentEntity>> GetReferencedContentsAsync(Guid schemaId, ICollection<Guid> ids)
public virtual async Task<IReadOnlyList<IContentEntity>> GetReferencedContentsAsync(Guid schemaId, ICollection<Guid> ids)
{
Guard.NotNull(ids, nameof(ids));

10
src/Squidex.Domain.Apps.Entities/Contents/ScheduleJob.cs

@ -22,17 +22,17 @@ namespace Squidex.Domain.Apps.Entities.Contents
public Instant DueTime { get; }
public ScheduleJob(Guid id, Status status, RefToken by, Instant due)
public ScheduleJob(Guid id, Status status, RefToken scheduledBy, Instant dueTime)
{
Id = id;
ScheduledBy = by;
ScheduledBy = scheduledBy;
Status = status;
DueTime = due;
DueTime = dueTime;
}
public static ScheduleJob Build(Status status, RefToken by, Instant due)
public static ScheduleJob Build(Status status, RefToken scheduledBy, Instant dueTime)
{
return new ScheduleJob(Guid.NewGuid(), status, by, due);
return new ScheduleJob(Guid.NewGuid(), status, scheduledBy, dueTime);
}
}
}

5
src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs

@ -50,6 +50,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.State
SimpleMapper.Map(@event, this);
UpdateData(null, @event.Data, false);
if (Status == default)
{
Status = Status.Draft;
}
}
protected void On(ContentChangesPublished @event)

2
src/Squidex.Domain.Apps.Entities/Contents/StatusForApi.cs

@ -10,6 +10,6 @@ namespace Squidex.Domain.Apps.Entities.Contents
public enum StatusForApi
{
PublishedOnly,
PublishedDraft,
All,
}
}

21
src/Squidex.Domain.Apps.Entities/EntityExtensions.cs

@ -0,0 +1,21 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities
{
public static class EntityExtensions
{
public static NamedId<Guid> NamedId(this IAppEntity entity)
{
return new NamedId<Guid>(entity.Id, entity.Name);
}
}
}

5
src/Squidex.Domain.Apps.Entities/Q.cs

@ -25,6 +25,11 @@ namespace Squidex.Domain.Apps.Entities
return Clone(c => c.ODataQuery = odataQuery);
}
public Q WithIds(params Guid[] ids)
{
return Clone(c => c.Ids = ids.ToList());
}
public Q WithIds(IEnumerable<Guid> ids)
{
return Clone(c => c.Ids = ids.ToList());

19
src/Squidex.Domain.Apps.Entities/QueryContext.cs

@ -27,8 +27,6 @@ namespace Squidex.Domain.Apps.Entities
public StatusForApi ApiStatus { get; private set; }
public StatusForFrontend FrontendStatus { get; private set; }
public IReadOnlyCollection<string> AssetUrlsToResolve { get; private set; }
public IReadOnlyCollection<Language> Languages { get; private set; }
@ -49,7 +47,7 @@ namespace Squidex.Domain.Apps.Entities
public QueryContext WithUnpublished(bool unpublished)
{
return WithApiStatus(unpublished ? StatusForApi.PublishedDraft : StatusForApi.PublishedOnly);
return WithApiStatus(unpublished ? StatusForApi.All : StatusForApi.PublishedOnly);
}
public QueryContext WithApiStatus(StatusForApi status)
@ -57,21 +55,6 @@ namespace Squidex.Domain.Apps.Entities
return Clone(c => c.ApiStatus = status);
}
public QueryContext WithFrontendStatus(StatusForFrontend status)
{
return Clone(c => c.FrontendStatus = status);
}
public QueryContext WithFrontendStatus(string status)
{
if (status != null && Enum.TryParse<StatusForFrontend>(status, out var result))
{
return WithFrontendStatus(result);
}
return this;
}
public QueryContext WithAssetUrlsToResolve(IEnumerable<string> fieldNames)
{
if (fieldNames != null)

18
src/Squidex.Domain.Apps.Entities/Rules/RuleGrain.cs

@ -41,35 +41,43 @@ namespace Squidex.Domain.Apps.Entities.Rules
switch (command)
{
case CreateRule createRule:
return CreateAsync(createRule, async c =>
return CreateReturnAsync(createRule, async c =>
{
await GuardRule.CanCreate(c, appProvider);
Create(c);
return Snapshot;
});
case UpdateRule updateRule:
return UpdateAsync(updateRule, async c =>
return UpdateReturnAsync(updateRule, async c =>
{
await GuardRule.CanUpdate(c, Snapshot.AppId.Id, appProvider);
Update(c);
return Snapshot;
});
case EnableRule enableRule:
return UpdateAsync(enableRule, c =>
return UpdateReturn(enableRule, c =>
{
GuardRule.CanEnable(c, Snapshot.RuleDef);
Enable(c);
return Snapshot;
});
case DisableRule disableRule:
return UpdateAsync(disableRule, c =>
return UpdateReturn(disableRule, c =>
{
GuardRule.CanDisable(c, Snapshot.RuleDef);
Disable(c);
return Snapshot;
});
case DeleteRule deleteRule:
return UpdateAsync(deleteRule, c =>
return Update(deleteRule, c =>
{
GuardRule.CanDelete(deleteRule);

24
src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardHelper.cs

@ -12,21 +12,26 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
{
public static class GuardHelper
{
public static IArrayField GetArrayFieldOrThrow(Schema schema, long parentId)
public static IArrayField GetArrayFieldOrThrow(Schema schema, long parentId, bool allowLocked)
{
if (!schema.FieldsById.TryGetValue(parentId, out var rootField) || !(rootField is IArrayField arrayField))
{
throw new DomainObjectNotFoundException(parentId.ToString(), "Fields", typeof(Schema));
}
if (!allowLocked)
{
EnsureNotLocked(arrayField);
}
return arrayField;
}
public static IField GetFieldOrThrow(Schema schema, long fieldId, long? parentId)
public static IField GetFieldOrThrow(Schema schema, long fieldId, long? parentId, bool allowLocked)
{
if (parentId.HasValue)
{
var arrayField = GetArrayFieldOrThrow(schema, parentId.Value);
var arrayField = GetArrayFieldOrThrow(schema, parentId.Value, allowLocked);
if (!arrayField.FieldsById.TryGetValue(fieldId, out var nestedField))
{
@ -41,7 +46,20 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
throw new DomainObjectNotFoundException(fieldId.ToString(), "Fields", typeof(Schema));
}
if (!allowLocked)
{
EnsureNotLocked(field);
}
return field;
}
private static void EnsureNotLocked(IField field)
{
if (field.IsLocked)
{
throw new DomainException("Schema field is locked.");
}
}
}
}

86
src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs

@ -56,7 +56,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
if (command.ParentFieldId.HasValue)
{
arrayField = GuardHelper.GetArrayFieldOrThrow(schema, command.ParentFieldId.Value);
arrayField = GuardHelper.GetArrayFieldOrThrow(schema, command.ParentFieldId.Value, false);
}
Validate.It(() => "Cannot reorder schema fields.", error =>
@ -142,49 +142,73 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
fieldIndex++;
fieldPrefix = $"Fields[{fieldIndex}]";
if (!field.Partitioning.IsValidPartitioning())
{
e(Not.Valid("Partitioning"), $"{fieldPrefix}.{nameof(field.Partitioning)}");
}
ValidateRootField(field, fieldPrefix, e);
}
ValidateField(field, fieldPrefix, e);
if (command.Fields.Select(x => x?.Name).Distinct().Count() != command.Fields.Count)
{
e("Fields cannot have duplicate names.", nameof(command.Fields));
}
}
}
if (field.Nested?.Count > 0)
{
if (field.Properties is ArrayFieldProperties)
{
var nestedIndex = 0;
var nestedPrefix = string.Empty;
private static void ValidateRootField(UpsertSchemaField field, string prefix, AddValidation e)
{
if (field == null)
{
e(Not.Defined("Field"), prefix);
}
else
{
if (!field.Partitioning.IsValidPartitioning())
{
e(Not.Valid("Partitioning"), $"{prefix}.{nameof(field.Partitioning)}");
}
foreach (var nestedField in field.Nested)
{
nestedIndex++;
nestedPrefix = $"{fieldPrefix}.Nested[{nestedIndex}]";
ValidateField(field, prefix, e);
if (nestedField.Properties is ArrayFieldProperties)
{
e("Nested field cannot be array fields.", $"{nestedPrefix}.{nameof(nestedField.Properties)}");
}
if (field.Nested?.Count > 0)
{
if (field.Properties is ArrayFieldProperties)
{
var nestedIndex = 0;
var nestedPrefix = string.Empty;
ValidateField(nestedField, nestedPrefix, e);
}
}
else if (field.Nested.Count > 0)
foreach (var nestedField in field.Nested)
{
e("Only array fields can have nested fields.", $"{fieldPrefix}.{nameof(field.Partitioning)}");
}
nestedIndex++;
nestedPrefix = $"{prefix}.Nested[{nestedIndex}]";
if (field.Nested.Select(x => x.Name).Distinct().Count() != field.Nested.Count)
{
e("Fields cannot have duplicate names.", $"{fieldPrefix}.Nested");
ValidateNestedField(nestedField, nestedPrefix, e);
}
}
else if (field.Nested.Count > 0)
{
e("Only array fields can have nested fields.", $"{prefix}.{nameof(field.Partitioning)}");
}
if (field.Nested.Select(x => x.Name).Distinct().Count() != field.Nested.Count)
{
e("Fields cannot have duplicate names.", $"{prefix}.Nested");
}
}
}
}
if (command.Fields.Select(x => x.Name).Distinct().Count() != command.Fields.Count)
private static void ValidateNestedField(UpsertSchemaNestedField nestedField, string prefix, AddValidation e)
{
if (nestedField == null)
{
e(Not.Defined("Field"), prefix);
}
else
{
if (nestedField.Properties is ArrayFieldProperties)
{
e("Fields cannot have duplicate names.", nameof(command.Fields));
e("Nested field cannot be array fields.", $"{prefix}.{nameof(nestedField.Properties)}");
}
ValidateField(nestedField, prefix, e);
}
}

50
src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchemaField.cs

@ -38,7 +38,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
if (command.ParentFieldId.HasValue)
{
var arrayField = GuardHelper.GetArrayFieldOrThrow(schema, command.ParentFieldId.Value);
var arrayField = GuardHelper.GetArrayFieldOrThrow(schema, command.ParentFieldId.Value, false);
if (arrayField.FieldsByName.ContainsKey(command.Name))
{
@ -64,12 +64,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
{
Guard.NotNull(command, nameof(command));
var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId);
if (field.IsLocked)
{
throw new DomainException("Schema field is already locked.");
}
var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId, false);
Validate.It(() => "Cannot update field.", e =>
{
@ -90,12 +85,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
{
Guard.NotNull(command, nameof(command));
var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId);
if (field.IsLocked)
{
throw new DomainException("Schema field is locked.");
}
var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId, false);
if (field.IsHidden)
{
@ -108,18 +98,30 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
}
}
public static void CanShow(Schema schema, ShowField command)
{
Guard.NotNull(command, nameof(command));
var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId, false);
if (!field.IsHidden)
{
throw new DomainException("Schema field is already visible.");
}
}
public static void CanDisable(Schema schema, DisableField command)
{
Guard.NotNull(command, nameof(command));
var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId);
var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId, false);
if (field.IsDisabled)
{
throw new DomainException("Schema field is already disabled.");
}
if (!field.IsForApi())
if (!field.IsForApi(true))
{
throw new DomainException("UI field cannot be disabled.");
}
@ -129,7 +131,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
{
Guard.NotNull(command, nameof(command));
var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId);
var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId, false);
if (field.IsLocked)
{
@ -137,23 +139,11 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
}
}
public static void CanShow(Schema schema, ShowField command)
{
Guard.NotNull(command, nameof(command));
var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId);
if (!field.IsHidden)
{
throw new DomainException("Schema field is already visible.");
}
}
public static void CanEnable(Schema schema, EnableField command)
{
Guard.NotNull(command, nameof(command));
var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId);
var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId, false);
if (!field.IsDisabled)
{
@ -165,7 +155,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
{
Guard.NotNull(command, nameof(command));
var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId);
var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId, false);
if (field.IsLocked)
{

78
src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs

@ -48,7 +48,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas
switch (command)
{
case AddField addField:
return UpdateAsync(addField, c =>
return UpdateReturn(addField, c =>
{
GuardSchemaField.CanAdd(Snapshot.SchemaDef, c);
@ -65,139 +65,171 @@ namespace Squidex.Domain.Apps.Entities.Schemas
id = ((IArrayField)Snapshot.SchemaDef.FieldsById[c.ParentFieldId.Value]).FieldsByName[c.Name].Id;
}
return EntityCreatedResult.Create(id, Version);
return Snapshot;
});
case CreateSchema createSchema:
return CreateAsync(createSchema, async c =>
return CreateReturnAsync(createSchema, async c =>
{
await GuardSchema.CanCreate(c, appProvider);
Create(c);
return Snapshot;
});
case SynchronizeSchema synchronizeSchema:
return UpdateAsync(synchronizeSchema, c =>
return UpdateReturn(synchronizeSchema, c =>
{
GuardSchema.CanSynchronize(c);
Synchronize(c);
return Snapshot;
});
case DeleteField deleteField:
return UpdateAsync(deleteField, c =>
return UpdateReturn(deleteField, c =>
{
GuardSchemaField.CanDelete(Snapshot.SchemaDef, deleteField);
DeleteField(c);
return Snapshot;
});
case LockField lockField:
return UpdateAsync(lockField, c =>
return UpdateReturn(lockField, c =>
{
GuardSchemaField.CanLock(Snapshot.SchemaDef, lockField);
LockField(c);
return Snapshot;
});
case HideField hideField:
return UpdateAsync(hideField, c =>
return UpdateReturn(hideField, c =>
{
GuardSchemaField.CanHide(Snapshot.SchemaDef, c);
HideField(c);
return Snapshot;
});
case ShowField showField:
return UpdateAsync(showField, c =>
return UpdateReturn(showField, c =>
{
GuardSchemaField.CanShow(Snapshot.SchemaDef, c);
ShowField(c);
return Snapshot;
});
case DisableField disableField:
return UpdateAsync(disableField, c =>
return UpdateReturn(disableField, c =>
{
GuardSchemaField.CanDisable(Snapshot.SchemaDef, c);
DisableField(c);
return Snapshot;
});
case EnableField enableField:
return UpdateAsync(enableField, c =>
return UpdateReturn(enableField, c =>
{
GuardSchemaField.CanEnable(Snapshot.SchemaDef, c);
EnableField(c);
return Snapshot;
});
case UpdateField updateField:
return UpdateAsync(updateField, c =>
return UpdateReturn(updateField, c =>
{
GuardSchemaField.CanUpdate(Snapshot.SchemaDef, c);
UpdateField(c);
return Snapshot;
});
case ReorderFields reorderFields:
return UpdateAsync(reorderFields, c =>
return UpdateReturn(reorderFields, c =>
{
GuardSchema.CanReorder(Snapshot.SchemaDef, c);
Reorder(c);
return Snapshot;
});
case UpdateSchema updateSchema:
return UpdateAsync(updateSchema, c =>
return UpdateReturn(updateSchema, c =>
{
GuardSchema.CanUpdate(Snapshot.SchemaDef, c);
Update(c);
return Snapshot;
});
case PublishSchema publishSchema:
return UpdateAsync(publishSchema, c =>
return UpdateReturn(publishSchema, c =>
{
GuardSchema.CanPublish(Snapshot.SchemaDef, c);
Publish(c);
return Snapshot;
});
case UnpublishSchema unpublishSchema:
return UpdateAsync(unpublishSchema, c =>
return UpdateReturn(unpublishSchema, c =>
{
GuardSchema.CanUnpublish(Snapshot.SchemaDef, c);
Unpublish(c);
return Snapshot;
});
case ConfigureScripts configureScripts:
return UpdateAsync(configureScripts, c =>
return UpdateReturn(configureScripts, c =>
{
GuardSchema.CanConfigureScripts(Snapshot.SchemaDef, c);
ConfigureScripts(c);
return Snapshot;
});
case ChangeCategory changeCategory:
return UpdateAsync(changeCategory, c =>
return UpdateReturn(changeCategory, c =>
{
GuardSchema.CanChangeCategory(Snapshot.SchemaDef, c);
ChangeCategory(c);
return Snapshot;
});
case ConfigurePreviewUrls configurePreviewUrls:
return UpdateAsync(configurePreviewUrls, c =>
return UpdateReturn(configurePreviewUrls, c =>
{
GuardSchema.CanConfigurePreviewUrls(c);
ConfigurePreviewUrls(c);
return Snapshot;
});
case DeleteSchema deleteSchema:
return UpdateAsync(deleteSchema, c =>
return Update(deleteSchema, c =>
{
GuardSchema.CanDelete(Snapshot.SchemaDef, c);
@ -321,7 +353,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas
{
if (id.HasValue && Snapshot.SchemaDef.FieldsById.TryGetValue(id.Value, out var field))
{
return NamedId.Of(field.Id, field.Name);
return field.NamedId();
}
return null;
@ -333,13 +365,13 @@ namespace Squidex.Domain.Apps.Entities.Schemas
{
if (Snapshot.SchemaDef.FieldsById.TryGetValue(pc.ParentFieldId.Value, out var field))
{
pe.ParentFieldId = NamedId.Of(field.Id, field.Name);
pe.ParentFieldId = field.NamedId();
if (command is FieldCommand fc && @event is FieldEvent fe)
{
if (field is IArrayField arrayField && arrayField.FieldsById.TryGetValue(fc.FieldId, out var nestedField))
{
fe.FieldId = NamedId.Of(nestedField.Id, nestedField.Name);
fe.FieldId = nestedField.NamedId();
}
}
}
@ -357,7 +389,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas
{
if (@event.SchemaId == null)
{
@event.SchemaId = NamedId.Of(Snapshot.Id, Snapshot.SchemaDef.Name);
@event.SchemaId = Snapshot.NamedId();
}
if (@event.AppId == null)

2
src/Squidex.Domain.Apps.Events/Contents/ContentCreated.cs

@ -13,6 +13,8 @@ namespace Squidex.Domain.Apps.Events.Contents
[EventType(nameof(ContentCreated))]
public sealed class ContentCreated : ContentEvent
{
public Status Status { get; set; }
public NamedContentData Data { get; set; }
}
}

2
src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs

@ -13,7 +13,7 @@ namespace Squidex.Domain.Apps.Events.Contents
[EventType(nameof(ContentStatusChanged))]
public sealed class ContentStatusChanged : ContentEvent
{
public StatusChange? Change { get; set; }
public StatusChange Change { get; set; }
public Status Status { get; set; }
}

16
src/Squidex.Domain.Users/UserManagerExtensions.cs

@ -115,7 +115,7 @@ namespace Squidex.Domain.Users
return result;
}
public static async Task<IdentityUser> CreateAsync(this UserManager<IdentityUser> userManager, IUserFactory factory, UserValues values)
public static async Task<UserWithClaims> CreateAsync(this UserManager<IdentityUser> userManager, IUserFactory factory, UserValues values)
{
var user = factory.Create(values.Email);
@ -142,10 +142,10 @@ namespace Squidex.Domain.Users
throw;
}
return user;
return await userManager.ResolveUserAsync(user);
}
public static async Task UpdateAsync(this UserManager<IdentityUser> userManager, string id, UserValues values)
public static async Task<UserWithClaims> UpdateAsync(this UserManager<IdentityUser> userManager, string id, UserValues values)
{
var user = await userManager.FindByIdAsync(id);
@ -155,6 +155,8 @@ namespace Squidex.Domain.Users
}
await UpdateAsync(userManager, user, values);
return await userManager.ResolveUserAsync(user);
}
public static async Task<IdentityResult> UpdateSafeAsync(this UserManager<IdentityUser> userManager, IdentityUser user, UserValues values)
@ -193,7 +195,7 @@ namespace Squidex.Domain.Users
}
}
public static async Task LockAsync(this UserManager<IdentityUser> userManager, string id)
public static async Task<UserWithClaims> LockAsync(this UserManager<IdentityUser> userManager, string id)
{
var user = await userManager.FindByIdAsync(id);
@ -203,9 +205,11 @@ namespace Squidex.Domain.Users
}
await DoChecked(() => userManager.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.AddYears(100)), "Cannot lock user.");
return await userManager.ResolveUserAsync(user);
}
public static async Task UnlockAsync(this UserManager<IdentityUser> userManager, string id)
public static async Task<UserWithClaims> UnlockAsync(this UserManager<IdentityUser> userManager, string id)
{
var user = await userManager.FindByIdAsync(id);
@ -215,6 +219,8 @@ namespace Squidex.Domain.Users
}
await DoChecked(() => userManager.SetLockoutEndDateAsync(user, null), "Cannot unlock user.");
return await userManager.ResolveUserAsync(user);
}
private static async Task DoChecked(Func<Task<IdentityResult>> action, string message)

6
src/Squidex.Infrastructure.MongoDb/Assets/MongoGridFsAssetStore.cs

@ -46,6 +46,8 @@ namespace Squidex.Infrastructure.Assets
public async Task CopyAsync(string sourceFileName, string targetFileName, CancellationToken ct = default)
{
Guard.NotNullOrEmpty(targetFileName, nameof(targetFileName));
try
{
var sourceName = GetFileName(sourceFileName, nameof(sourceFileName));
@ -63,6 +65,8 @@ namespace Squidex.Infrastructure.Assets
public async Task DownloadAsync(string fileName, Stream stream, CancellationToken ct = default)
{
Guard.NotNull(stream, nameof(stream));
try
{
var name = GetFileName(fileName, nameof(fileName));
@ -80,6 +84,8 @@ namespace Squidex.Infrastructure.Assets
public async Task UploadAsync(string fileName, Stream stream, bool overwrite = false, CancellationToken ct = default)
{
Guard.NotNull(stream, nameof(stream));
try
{
var name = GetFileName(fileName, nameof(fileName));

158
src/Squidex.Infrastructure/Assets/FTPAssetStore.cs

@ -0,0 +1,158 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using FluentFTP;
using Squidex.Infrastructure.Log;
namespace Squidex.Infrastructure.Assets
{
public sealed class FTPAssetStore : IAssetStore, IInitializable
{
private readonly string path;
private readonly ISemanticLog log;
private readonly Func<IFtpClient> factory;
public FTPAssetStore(Func<IFtpClient> factory, string path, ISemanticLog log)
{
Guard.NotNull(factory, nameof(factory));
Guard.NotNullOrEmpty(path, nameof(path));
Guard.NotNull(log, nameof(log));
this.factory = factory;
this.path = path;
this.log = log;
}
public string GeneratePublicUrl(string fileName)
{
return null;
}
public async Task InitializeAsync(CancellationToken ct = default)
{
using (var client = factory())
{
await client.ConnectAsync(ct);
if (!await client.DirectoryExistsAsync(path, ct))
{
await client.CreateDirectoryAsync(path, ct);
}
}
log.LogInformation(w => w
.WriteProperty("action", "FTPAssetStoreConfigured")
.WriteProperty("path", path));
}
public async Task CopyAsync(string sourceFileName, string targetFileName, CancellationToken ct = default)
{
Guard.NotNullOrEmpty(sourceFileName, nameof(sourceFileName));
Guard.NotNullOrEmpty(targetFileName, nameof(targetFileName));
using (var client = GetFtpClient())
{
var tempPath = Path.Combine(Path.GetTempPath(), Path.GetTempFileName());
using (var stream = new FileStream(tempPath, FileMode.Create, FileAccess.ReadWrite, FileShare.None, 4096, FileOptions.DeleteOnClose))
{
await DownloadAsync(client, sourceFileName, stream, ct);
await UploadAsync(client, targetFileName, stream, false, ct);
}
}
}
public async Task DownloadAsync(string fileName, Stream stream, CancellationToken ct = default)
{
Guard.NotNullOrEmpty(fileName, nameof(fileName));
Guard.NotNull(stream, nameof(stream));
using (var client = GetFtpClient())
{
await DownloadAsync(client, fileName, stream, ct);
}
}
public async Task UploadAsync(string fileName, Stream stream, bool overwrite = false, CancellationToken ct = default)
{
Guard.NotNullOrEmpty(fileName, nameof(fileName));
Guard.NotNull(stream, nameof(stream));
using (var client = GetFtpClient())
{
await UploadAsync(client, fileName, stream, overwrite, ct);
}
}
private static async Task DownloadAsync(IFtpClient client, string fileName, Stream stream, CancellationToken ct)
{
try
{
await client.DownloadAsync(stream, fileName, token: ct);
}
catch (FtpException ex) when (IsNotFound(ex))
{
throw new AssetNotFoundException(fileName, ex);
}
}
private static async Task UploadAsync(IFtpClient client, string fileName, Stream stream, bool overwrite, CancellationToken ct)
{
if (!overwrite && await client.FileExistsAsync(fileName, ct))
{
throw new AssetAlreadyExistsException(fileName);
}
await client.UploadAsync(stream, fileName, overwrite ? FtpExists.Overwrite : FtpExists.Skip, true, null, ct);
}
public async Task DeleteAsync(string fileName)
{
Guard.NotNullOrEmpty(fileName, nameof(fileName));
using (var client = GetFtpClient())
{
try
{
await client.DeleteFileAsync(fileName);
}
catch (FtpException ex)
{
if (!IsNotFound(ex))
{
throw ex;
}
}
}
}
private IFtpClient GetFtpClient()
{
var client = factory();
client.Connect();
client.SetWorkingDirectory(path);
return client;
}
private static bool IsNotFound(Exception exception)
{
if (exception is FtpCommandException command)
{
return command.CompletionCode == "550";
}
return exception.InnerException != null && IsNotFound(exception.InnerException);
}
}
}

6
src/Squidex.Infrastructure/Assets/FolderAssetStore.cs

@ -82,7 +82,7 @@ namespace Squidex.Infrastructure.Assets
public async Task DownloadAsync(string fileName, Stream stream, CancellationToken ct = default)
{
Guard.NotNullOrEmpty(fileName, nameof(fileName));
Guard.NotNull(stream, nameof(stream));
var file = GetFile(fileName);
@ -101,7 +101,7 @@ namespace Squidex.Infrastructure.Assets
public async Task UploadAsync(string fileName, Stream stream, bool overwrite = false, CancellationToken ct = default)
{
Guard.NotNullOrEmpty(fileName, nameof(fileName));
Guard.NotNull(stream, nameof(stream));
var file = GetFile(fileName);
@ -120,8 +120,6 @@ namespace Squidex.Infrastructure.Assets
public Task DeleteAsync(string fileName)
{
Guard.NotNullOrEmpty(fileName, nameof(fileName));
var file = GetFile(fileName);
file.Delete();

2
src/Squidex.Infrastructure/Assets/MemoryAssetStore.cs

@ -43,6 +43,7 @@ namespace Squidex.Infrastructure.Assets
public virtual async Task DownloadAsync(string fileName, Stream stream, CancellationToken ct = default)
{
Guard.NotNullOrEmpty(fileName, nameof(fileName));
Guard.NotNull(stream, nameof(stream));
if (!streams.TryGetValue(fileName, out var sourceStream))
{
@ -65,6 +66,7 @@ namespace Squidex.Infrastructure.Assets
public virtual async Task UploadAsync(string fileName, Stream stream, bool overwrite = false, CancellationToken ct = default)
{
Guard.NotNullOrEmpty(fileName, nameof(fileName));
Guard.NotNull(stream, nameof(stream));
var memoryStream = new MemoryStream();

8
src/Squidex.Infrastructure/CollectionExtensions.cs

@ -13,6 +13,14 @@ namespace Squidex.Infrastructure
{
public static class CollectionExtensions
{
public static void AddRange<T>(this ICollection<T> target, IEnumerable<T> source)
{
foreach (var value in source)
{
target.Add(value);
}
}
public static IEnumerable<T> Shuffle<T>(this IEnumerable<T> enumerable)
{
var random = new Random();

12
src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs

@ -93,7 +93,7 @@ namespace Squidex.Infrastructure.Commands
return InvokeAsync(command, handler, Mode.Create);
}
protected Task<object> CreateReturnAsync<TCommand>(TCommand command, Func<TCommand, object> handler) where TCommand : class, IAggregateCommand
protected Task<object> CreateReturn<TCommand>(TCommand command, Func<TCommand, object> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler?.ToAsync(), Mode.Create);
}
@ -103,7 +103,7 @@ namespace Squidex.Infrastructure.Commands
return InvokeAsync(command, handler.ToDefault<TCommand, object>(), Mode.Create);
}
protected Task<object> CreateAsync<TCommand>(TCommand command, Action<TCommand> handler) where TCommand : class, IAggregateCommand
protected Task<object> Create<TCommand>(TCommand command, Action<TCommand> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler?.ToDefault<TCommand, object>()?.ToAsync(), Mode.Create);
}
@ -113,7 +113,7 @@ namespace Squidex.Infrastructure.Commands
return InvokeAsync(command, handler, Mode.Update);
}
protected Task<object> UpdateAsync<TCommand>(TCommand command, Func<TCommand, object> handler) where TCommand : class, IAggregateCommand
protected Task<object> UpdateReturn<TCommand>(TCommand command, Func<TCommand, object> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler?.ToAsync(), Mode.Update);
}
@ -123,7 +123,7 @@ namespace Squidex.Infrastructure.Commands
return InvokeAsync(command, handler?.ToDefault<TCommand, object>(), Mode.Update);
}
protected Task<object> UpdateAsync<TCommand>(TCommand command, Action<TCommand> handler) where TCommand : class, IAggregateCommand
protected Task<object> Update<TCommand>(TCommand command, Action<TCommand> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler?.ToDefault<TCommand, object>()?.ToAsync(), Mode.Update);
}
@ -133,7 +133,7 @@ namespace Squidex.Infrastructure.Commands
return InvokeAsync(command, handler, Mode.Upsert);
}
protected Task<object> UpsertAsync<TCommand>(TCommand command, Func<TCommand, object> handler) where TCommand : class, IAggregateCommand
protected Task<object> UpsertReturn<TCommand>(TCommand command, Func<TCommand, object> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler?.ToAsync(), Mode.Upsert);
}
@ -143,7 +143,7 @@ namespace Squidex.Infrastructure.Commands
return InvokeAsync(command, handler?.ToDefault<TCommand, object>(), Mode.Upsert);
}
protected Task<object> UpsertAsync<TCommand>(TCommand command, Action<TCommand> handler) where TCommand : class, IAggregateCommand
protected Task<object> Upsert<TCommand>(TCommand command, Action<TCommand> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler?.ToDefault<TCommand, object>()?.ToAsync(), Mode.Upsert);
}

29
src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs

@ -58,7 +58,12 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
public Task<Immutable<EventConsumerInfo>> GetStateAsync()
{
return Task.FromResult(State.ToInfo(eventConsumer.Name).AsImmutable());
return Task.FromResult(CreateInfo());
}
private Immutable<EventConsumerInfo> CreateInfo()
{
return State.ToInfo(eventConsumer.Name).AsImmutable();
}
public Task OnEventAsync(Immutable<IEventSubscription> subscription, Immutable<StoredEvent> storedEvent)
@ -109,39 +114,43 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
return TaskHelper.Done;
}
public Task StartAsync()
public async Task<Immutable<EventConsumerInfo>> StartAsync()
{
if (!State.IsStopped)
{
return TaskHelper.Done;
return CreateInfo();
}
return DoAndUpdateStateAsync(() =>
await DoAndUpdateStateAsync(() =>
{
Subscribe(State.Position);
State = State.Started();
});
return CreateInfo();
}
public Task StopAsync()
public async Task<Immutable<EventConsumerInfo>> StopAsync()
{
if (State.IsStopped)
{
return TaskHelper.Done;
return CreateInfo();
}
return DoAndUpdateStateAsync(() =>
await DoAndUpdateStateAsync(() =>
{
Unsubscribe();
State = State.Stopped();
});
return CreateInfo();
}
public Task ResetAsync()
public async Task<Immutable<EventConsumerInfo>> ResetAsync()
{
return DoAndUpdateStateAsync(async () =>
await DoAndUpdateStateAsync(async () =>
{
Unsubscribe();
@ -151,6 +160,8 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
State = State.Reset();
});
return CreateInfo();
}
private Task DoAndUpdateStateAsync(Action action, [CallerMemberName] string caller = null)

12
src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerManagerGrain.cs

@ -74,33 +74,31 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
{
return Task.WhenAll(
eventConsumers
.Select(c => GrainFactory.GetGrain<IEventConsumerGrain>(c.Name))
.Select(c => c.StartAsync()));
.Select(c => StartAsync(c.Name)));
}
public Task StopAllAsync()
{
return Task.WhenAll(
eventConsumers
.Select(c => GrainFactory.GetGrain<IEventConsumerGrain>(c.Name))
.Select(c => c.StopAsync()));
.Select(c => StopAsync(c.Name)));
}
public Task ResetAsync(string consumerName)
public Task<Immutable<EventConsumerInfo>> ResetAsync(string consumerName)
{
var eventConsumer = GrainFactory.GetGrain<IEventConsumerGrain>(consumerName);
return eventConsumer.ResetAsync();
}
public Task StartAsync(string consumerName)
public Task<Immutable<EventConsumerInfo>> StartAsync(string consumerName)
{
var eventConsumer = GrainFactory.GetGrain<IEventConsumerGrain>(consumerName);
return eventConsumer.StartAsync();
}
public Task StopAsync(string consumerName)
public Task<Immutable<EventConsumerInfo>> StopAsync(string consumerName)
{
var eventConsumer = GrainFactory.GetGrain<IEventConsumerGrain>(consumerName);

6
src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerGrain.cs

@ -16,11 +16,11 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
{
Task<Immutable<EventConsumerInfo>> GetStateAsync();
Task StopAsync();
Task<Immutable<EventConsumerInfo>> StopAsync();
Task StartAsync();
Task<Immutable<EventConsumerInfo>> StartAsync();
Task ResetAsync();
Task<Immutable<EventConsumerInfo>> ResetAsync();
Task OnEventAsync(Immutable<IEventSubscription> subscription, Immutable<StoredEvent> storedEvent);

10
src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerManagerGrain.cs

@ -16,15 +16,15 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
{
Task ActivateAsync(string streamName);
Task StopAllAsync();
Task StartAllAsync();
Task StopAsync(string consumerName);
Task StopAllAsync();
Task StartAllAsync();
Task<Immutable<EventConsumerInfo>> StopAsync(string consumerName);
Task StartAsync(string consumerName);
Task<Immutable<EventConsumerInfo>> StartAsync(string consumerName);
Task ResetAsync(string consumerName);
Task<Immutable<EventConsumerInfo>> ResetAsync(string consumerName);
Task<Immutable<List<EventConsumerInfo>>> GetConsumersAsync();
}

1
src/Squidex.Infrastructure/Squidex.Infrastructure.csproj

@ -8,6 +8,7 @@
<DebugSymbols>True</DebugSymbols>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentFTP" Version="24.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="2.2.0" />

10
src/Squidex.Shared/Permissions.cs

@ -37,8 +37,6 @@ namespace Squidex.Shared
public const string AdminAppCreate = "squidex.admin.apps.create";
public const string AdminRestore = "squidex.admin.restore";
public const string AdminRestoreRead = "squidex.admin.restore.read";
public const string AdminRestoreCreate = "squidex.admin.restore.create";
public const string AdminEvents = "squidex.admin.events";
public const string AdminEventsRead = "squidex.admin.events.read";
@ -79,7 +77,6 @@ namespace Squidex.Shared
public const string AppRolesDelete = "squidex.apps.{app}.roles.delete";
public const string AppPatterns = "squidex.apps.{app}.patterns";
public const string AppPatternsRead = "squidex.apps.{app}.patterns.read";
public const string AppPatternsCreate = "squidex.apps.{app}.patterns.create";
public const string AppPatternsUpdate = "squidex.apps.{app}.patterns.update";
public const string AppPatternsDelete = "squidex.apps.{app}.patterns.delete";
@ -108,7 +105,6 @@ namespace Squidex.Shared
public const string AppRulesDelete = "squidex.apps.{app}.rules.delete";
public const string AppSchemas = "squidex.apps.{app}.schemas.{name}";
public const string AppSchemasRead = "squidex.apps.{app}.schemas.{name}.read";
public const string AppSchemasCreate = "squidex.apps.{app}.schemas.{name}.create";
public const string AppSchemasUpdate = "squidex.apps.{app}.schemas.{name}.update";
public const string AppSchemasScripts = "squidex.apps.{app}.schemas.{name}.scripts";
@ -117,14 +113,10 @@ namespace Squidex.Shared
public const string AppContents = "squidex.apps.{app}.contents.{name}";
public const string AppContentsRead = "squidex.apps.{app}.contents.{name}.read";
public const string AppContentsGraphQL = "squidex.apps.{app}.contents.{name}.graphql";
public const string AppContentsCreate = "squidex.apps.{app}.contents.{name}.create";
public const string AppContentsUpdate = "squidex.apps.{app}.contents.{name}.update";
public const string AppContentsStatus = "squidex.apps.{app}.contents.{name}.status.{status}";
public const string AppContentsDiscard = "squidex.apps.{app}.contents.{name}.discard";
public const string AppContentsArchive = "squidex.apps.{app}.contents.{name}.archive";
public const string AppContentsRestore = "squidex.apps.{app}.contents.{name}.restore";
public const string AppContentsPublish = "squidex.apps.{app}.contents.{name}.publish";
public const string AppContentsUnpublish = "squidex.apps.{app}.contents.{name}.unpublish";
public const string AppContentsDelete = "squidex.apps.{app}.contents.{name}.delete";
public const string AppApi = "squidex.apps.{app}.api";

2
src/Squidex.Web/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs

@ -57,7 +57,7 @@ namespace Squidex.Web.CommandMiddlewares
throw new InvalidOperationException("Cannot resolve app.");
}
return NamedId.Of(appFeature.App.Id, appFeature.App.Name);
return appFeature.App.NamedId();
}
}
}

2
src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs

@ -69,7 +69,7 @@ namespace Squidex.Web.CommandMiddlewares
if (appFeature?.App != null)
{
appId = NamedId.Of(appFeature.App.Id, appFeature.App.Name);
appId = appFeature.App.NamedId();
}
}

8
src/Squidex.Web/Extensions.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Security.Claims;
using Squidex.Infrastructure.Security;
@ -40,5 +41,12 @@ namespace Squidex.Web
return (null, null);
}
public static bool IsUser(this ApiController controller, string userId)
{
var subject = controller.User.OpenIdSubject();
return string.Equals(subject, userId, StringComparison.OrdinalIgnoreCase);
}
}
}

76
src/Squidex.Web/PermissionExtensions.cs

@ -0,0 +1,76 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.AspNetCore.Http;
using Squidex.Infrastructure.Security;
using Squidex.Shared.Identity;
namespace Squidex.Web
{
public static class PermissionExtensions
{
private sealed class PermissionFeature
{
public PermissionSet Permissions { get; }
public PermissionFeature(PermissionSet permissions)
{
Permissions = permissions;
}
}
public static PermissionSet Permissions(this HttpContext httpContext)
{
var feature = httpContext.Features.Get<PermissionFeature>();
if (feature == null)
{
feature = new PermissionFeature(httpContext.User.Permissions());
httpContext.Features.Set(feature);
}
return feature.Permissions;
}
public static bool HasPermission(this HttpContext httpContext, Permission permission, PermissionSet permissions = null)
{
return httpContext.Permissions().Includes(permission) || permissions?.Includes(permission) == true;
}
public static bool HasPermission(this HttpContext httpContext, string id, string app = "*", string schema = "*", PermissionSet permissions = null)
{
return httpContext.HasPermission(Shared.Permissions.ForApp(id, app, schema), permissions);
}
public static bool HasPermission(this ApiController controller, Permission permission, PermissionSet permissions = null)
{
return controller.HttpContext.HasPermission(permission, permissions);
}
public static bool HasPermission(this ApiController controller, string id, string app = "*", string schema = "*", PermissionSet permissions = null)
{
if (app == "*")
{
if (controller.RouteData.Values.TryGetValue("app", out var value) && value is string s)
{
app = s;
}
}
if (schema == "*")
{
if (controller.RouteData.Values.TryGetValue("name", out var value) && value is string s)
{
schema = s;
}
}
return controller.HasPermission(Shared.Permissions.ForApp(id, app, schema), permissions);
}
}
}

5
src/Squidex.Web/Pipeline/AppResolver.cs

@ -65,7 +65,10 @@ namespace Squidex.Web.Pipeline
{
var identity = user.Identities.First();
identity.AddClaim(new Claim(ClaimTypes.Role, role));
if (!string.IsNullOrWhiteSpace(role))
{
identity.AddClaim(new Claim(ClaimTypes.Role, role));
}
foreach (var permission in permissions)
{

61
src/Squidex.Web/Resource.cs

@ -0,0 +1,61 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Newtonsoft.Json;
using Squidex.Infrastructure;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace Squidex.Web
{
public abstract class Resource
{
[JsonProperty("_links")]
[Required]
[Display(Description = "The links.")]
public Dictionary<string, ResourceLink> Links { get; } = new Dictionary<string, ResourceLink>();
public void AddSelfLink(string href)
{
AddGetLink("self", href);
}
public void AddGetLink(string rel, string href)
{
AddLink(rel, "GET", href);
}
public void AddPatchLink(string rel, string href)
{
AddLink(rel, "PATCH", href);
}
public void AddPostLink(string rel, string href)
{
AddLink(rel, "POST", href);
}
public void AddPutLink(string rel, string href)
{
AddLink(rel, "PUT", href);
}
public void AddDeleteLink(string rel, string href)
{
AddLink(rel, "DELETE", href);
}
public void AddLink(string rel, string method, string href)
{
Guard.NotNullOrEmpty(rel, nameof(rel));
Guard.NotNullOrEmpty(href, nameof(href));
Guard.NotNullOrEmpty(method, nameof(method));
Links[rel] = new ResourceLink { Href = href, Method = method };
}
}
}

16
src/Squidex/Areas/Api/Controllers/Users/Models/UserCreatedDto.cs → src/Squidex.Web/ResourceLink.cs

@ -7,20 +7,16 @@
using System.ComponentModel.DataAnnotations;
namespace Squidex.Areas.Api.Controllers.Users.Models
namespace Squidex.Web
{
public sealed class UserCreatedDto
public class ResourceLink
{
/// <summary>
/// The id of the user.
/// </summary>
[Required]
public string Id { get; set; }
[Display(Description = "The link url.")]
public string Href { get; set; }
/// <summary>
/// Additional permissions for the user.
/// </summary>
[Required]
public string[] Permissions { get; set; }
[Display(Description = "The link method.")]
public string Method { get; set; }
}
}

44
src/Squidex.Web/UrlHelperExtensions.cs

@ -0,0 +1,44 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.AspNetCore.Mvc;
using System;
namespace Squidex.Web
{
public static class UrlHelperExtensions
{
private static class NameOf<T>
{
public static readonly string Controller;
static NameOf()
{
const string suffix = "Controller";
var name = typeof(T).Name;
if (name.EndsWith(suffix))
{
name = name.Substring(0, name.Length - suffix.Length);
}
Controller = name;
}
}
public static string Url<T>(this IUrlHelper urlHelper, Func<T, string> action, object values = null) where T : Controller
{
return urlHelper.Action(action(null), NameOf<T>.Controller, values);
}
public static string Url<T>(this Controller controller, Func<T, string> action, object values = null) where T : Controller
{
return controller.Url.Url<T>(action, values);
}
}
}

70
src/Squidex/Areas/Api/Config/Swagger/ErrorDtoProcessor.cs

@ -0,0 +1,70 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Linq;
using System.Threading.Tasks;
using NJsonSchema;
using NSwag;
using NSwag.SwaggerGeneration.Processors;
using NSwag.SwaggerGeneration.Processors.Contexts;
using Squidex.ClientLibrary.Management;
using Squidex.Pipeline.Swagger;
namespace Squidex.Areas.Api.Config.Swagger
{
public sealed class ErrorDtoProcessor : IDocumentProcessor
{
public async Task ProcessAsync(DocumentProcessorContext context)
{
var errorSchema = await GetErrorSchemaAsync(context);
foreach (var operation in context.Document.Paths.Values.SelectMany(x => x.Values))
{
AddErrorResponses(operation, errorSchema);
CleanupResponses(operation);
}
}
private static void AddErrorResponses(SwaggerOperation operation, JsonSchema4 errorSchema)
{
if (!operation.Responses.ContainsKey("500"))
{
operation.AddResponse("500", "Operation failed", errorSchema);
}
foreach (var (code, response) in operation.Responses)
{
if (code != "404" && code.StartsWith("4", StringComparison.OrdinalIgnoreCase) && response.Schema == null)
{
response.Schema = errorSchema;
}
}
}
private static void CleanupResponses(SwaggerOperation operation)
{
foreach (var (code, response) in operation.Responses.ToList())
{
if (string.IsNullOrWhiteSpace(response.Description) ||
response.Description?.Contains("=&gt;") == true ||
response.Description?.Contains("=>") == true)
{
operation.Responses.Remove(code);
}
}
}
private Task<JsonSchema4> GetErrorSchemaAsync(DocumentProcessorContext context)
{
var errorType = typeof(ErrorDto);
return context.SchemaGenerator.GenerateWithReferenceAsync<JsonSchema4>(errorType, Enumerable.Empty<Attribute>(), context.SchemaResolver);
}
}
}

2
src/Squidex/Areas/Api/Config/Swagger/FixProcessor.cs

@ -13,7 +13,7 @@ using Squidex.Infrastructure.Tasks;
namespace Squidex.Areas.Api.Config.Swagger
{
public class FixProcessor : IOperationProcessor
public sealed class FixProcessor : IOperationProcessor
{
private static readonly JsonSchema4 StringSchema = new JsonSchema4 { Type = JsonObjectType.String };

29
src/Squidex/Areas/Api/Config/Swagger/SecurityProcessor.cs

@ -15,7 +15,7 @@ using Squidex.Web;
namespace Squidex.Areas.Api.Config.Swagger
{
public class SecurityProcessor : SecurityDefinitionAppender
public sealed class SecurityProcessor : SecurityDefinitionAppender
{
public SecurityProcessor(IOptions<UrlsOptions> urlOptions)
: base(Constants.SecurityDefinition, Enumerable.Empty<string>(), CreateOAuthSchema(urlOptions.Value))
@ -24,26 +24,33 @@ namespace Squidex.Areas.Api.Config.Swagger
private static SwaggerSecurityScheme CreateOAuthSchema(UrlsOptions urlOptions)
{
var securityScheme = new SwaggerSecurityScheme();
var security = new SwaggerSecurityScheme
{
Type = SwaggerSecuritySchemeType.OAuth2
};
var tokenUrl = urlOptions.BuildUrl($"{Constants.IdentityServerPrefix}/connect/token", false);
securityScheme.TokenUrl = tokenUrl;
security.TokenUrl = tokenUrl;
var securityDocs = NSwagHelper.LoadDocs("security");
var securityText = securityDocs.Replace("<TOKEN_URL>", tokenUrl);
SetupDescription(security, tokenUrl);
securityScheme.Description = securityText;
securityScheme.Type = SwaggerSecuritySchemeType.OAuth2;
securityScheme.Flow = SwaggerOAuth2Flow.Application;
security.Flow = SwaggerOAuth2Flow.Application;
securityScheme.Scopes = new Dictionary<string, string>
security.Scopes = new Dictionary<string, string>
{
[Constants.ApiScope] = "Read and write access to the API"
};
return securityScheme;
return security;
}
private static void SetupDescription(SwaggerSecurityScheme securityScheme, string tokenUrl)
{
var securityDocs = NSwagHelper.LoadDocs("security");
var securityText = securityDocs.Replace("<TOKEN_URL>", tokenUrl);
securityScheme.Description = securityText;
}
}
}

7
src/Squidex/Areas/Api/Config/Swagger/SwaggerServices.cs

@ -14,6 +14,7 @@ using NSwag.SwaggerGeneration;
using NSwag.SwaggerGeneration.Processors;
using Squidex.Areas.Api.Controllers.Contents.Generator;
using Squidex.Areas.Api.Controllers.Rules.Models;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure;
namespace Squidex.Areas.Api.Config.Swagger
@ -22,6 +23,9 @@ namespace Squidex.Areas.Api.Config.Swagger
{
public static void AddMySwaggerSettings(this IServiceCollection services)
{
services.AddSingletonAs<ErrorDtoProcessor>()
.As<IDocumentProcessor>();
services.AddSingletonAs<RuleActionProcessor>()
.As<IDocumentProcessor>();
@ -73,7 +77,8 @@ namespace Squidex.Areas.Api.Config.Swagger
}),
new PrimitiveTypeMapper(typeof(Language), s => s.Type = JsonObjectType.String),
new PrimitiveTypeMapper(typeof(RefToken), s => s.Type = JsonObjectType.String)
new PrimitiveTypeMapper(typeof(RefToken), s => s.Type = JsonObjectType.String),
new PrimitiveTypeMapper(typeof(Status), s => s.Type = JsonObjectType.String),
};
}
}

52
src/Squidex/Areas/Api/Config/Swagger/XmlResponseTypesProcessor.cs

@ -5,16 +5,13 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Linq;
using System;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using NJsonSchema.Infrastructure;
using NSwag;
using NSwag.SwaggerGeneration.Processors;
using NSwag.SwaggerGeneration.Processors.Contexts;
using Squidex.Pipeline.Swagger;
#pragma warning disable RECS0033 // Convert 'if' to '||' expression
namespace Squidex.Areas.Api.Config.Swagger
{
@ -26,46 +23,33 @@ namespace Squidex.Areas.Api.Config.Swagger
{
var operation = context.OperationDescription.Operation;
var returnsDescription = await context.MethodInfo.GetXmlDocumentationTagAsync("returns") ?? string.Empty;
var returnsDescription = await context.MethodInfo.GetXmlDocumentationTagAsync("returns");
foreach (Match match in ResponseRegex.Matches(returnsDescription))
if (!string.IsNullOrWhiteSpace(returnsDescription))
{
var statusCode = match.Groups["Code"].Value;
if (!operation.Responses.TryGetValue(statusCode, out var response))
foreach (Match match in ResponseRegex.Matches(returnsDescription))
{
response = new SwaggerResponse();
var statusCode = match.Groups["Code"].Value;
operation.Responses[statusCode] = response;
}
if (!operation.Responses.TryGetValue(statusCode, out var response))
{
response = new SwaggerResponse();
response.Description = match.Groups["Description"].Value;
}
operation.Responses[statusCode] = response;
}
await AddInternalErrorResponseAsync(context, operation);
var description = match.Groups["Description"].Value;
CleanupResponses(operation);
if (description.Contains("=&gt;"))
{
throw new InvalidOperationException("Description not formatted correcly.");
}
return true;
}
private static async Task AddInternalErrorResponseAsync(OperationProcessorContext context, SwaggerOperation operation)
{
if (!operation.Responses.ContainsKey("500"))
{
operation.AddResponse("500", "Operation failed", await context.SchemaGenerator.GetErrorDtoSchemaAsync(context.SchemaResolver));
}
}
private static void CleanupResponses(SwaggerOperation operation)
{
foreach (var (code, response) in operation.Responses.ToList())
{
if (string.IsNullOrWhiteSpace(response.Description) || response.Description?.Contains("=>") == true)
{
operation.Responses.Remove(code);
response.Description = description;
}
}
return true;
}
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save