diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs index 0319305bc..eba2e0b88 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs @@ -10,7 +10,6 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.OData.UriParser; -using MongoDB.Bson; using MongoDB.Driver; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Apps; diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentCommand.cs b/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentCommand.cs index 06b65cfc1..3629bdfdc 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentCommand.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentCommand.cs @@ -6,15 +6,12 @@ // ========================================================================== using System; -using System.Security.Claims; using Squidex.Infrastructure.Commands; namespace Squidex.Domain.Apps.Entities.Contents.Commands { public abstract class ContentCommand : SchemaCommand, IAggregateCommand { - public ClaimsPrincipal User { get; set; } - public Guid ContentId { get; set; } Guid IAggregateCommand.AggregateId diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs new file mode 100644 index 000000000..9c0bc7af7 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs @@ -0,0 +1,56 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +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 +{ + public sealed class ContentEntity : IContentEntity + { + public Guid Id { get; set; } + + public Guid AppId { get; set; } + + public long Version { get; set; } + + public Instant Created { get; set; } + + public Instant LastModified { get; set; } + + public RefToken CreatedBy { get; set; } + + public RefToken LastModifiedBy { get; set; } + + public NamedContentData Data { get; set; } + + public Status Status { get; set; } + + public static ContentEntity Create(CreateContent command, EntityCreatedResult 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; + } + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs index ae01ee691..ebd65e858 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs @@ -12,7 +12,6 @@ using System.Security.Claims; using System.Threading.Tasks; using Microsoft.OData; using Microsoft.OData.UriParser; -using NodaTime; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Entities.Apps; @@ -122,7 +121,7 @@ namespace Squidex.Domain.Apps.Entities.Contents foreach (var content in contents) { var contentData = scriptEngine.Transform(new ScriptContext { User = user, Data = content.Data, ContentId = content.Id }, scriptText); - var contentResult = SimpleMapper.Map(content, new Content()); + var contentResult = SimpleMapper.Map(content, new ContentEntity()); contentResult.Data = contentData; @@ -199,23 +198,5 @@ namespace Squidex.Domain.Apps.Entities.Contents return status; } - - private sealed class Content : IContentEntity - { - public Guid Id { get; set; } - public Guid AppId { get; set; } - - public long Version { get; set; } - - public Instant Created { get; set; } - public Instant LastModified { get; set; } - - public RefToken CreatedBy { get; set; } - public RefToken LastModifiedBy { get; set; } - - public NamedContentData Data { get; set; } - - public Status Status { get; set; } - } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs index d970ca7e6..eb3d9f315 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs @@ -13,6 +13,7 @@ using Microsoft.Extensions.Caching.Memory; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Assets.Repositories; using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { @@ -20,6 +21,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10); private readonly IContentQueryService contentQuery; + private readonly ICommandBus commandBus; private readonly IGraphQLUrlGenerator urlGenerator; private readonly IAssetRepository assetRepository; private readonly IAppProvider appProvider; @@ -27,17 +29,20 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL public CachingGraphQLService(IMemoryCache cache, IAppProvider appProvider, IAssetRepository assetRepository, + ICommandBus commandBus, IContentQueryService contentQuery, IGraphQLUrlGenerator urlGenerator) : base(cache) { Guard.NotNull(appProvider, nameof(appProvider)); Guard.NotNull(assetRepository, nameof(assetRepository)); - Guard.NotNull(contentQuery, nameof(urlGenerator)); + Guard.NotNull(commandBus, nameof(commandBus)); Guard.NotNull(contentQuery, nameof(contentQuery)); + Guard.NotNull(urlGenerator, nameof(urlGenerator)); this.appProvider = appProvider; this.assetRepository = assetRepository; + this.commandBus = commandBus; this.contentQuery = contentQuery; this.urlGenerator = urlGenerator; } @@ -53,9 +58,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } var modelContext = await GetModelAsync(app); - var queryContext = new GraphQLQueryContext(app, assetRepository, contentQuery, user, urlGenerator); - return await modelContext.ExecuteAsync(queryContext, query); + var ctx = new GraphQLExecutionContext(app, assetRepository, commandBus, contentQuery, user, urlGenerator); + + return await modelContext.ExecuteAsync(ctx, query); } private async Task GetModelAsync(IAppEntity app) diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLQueryContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs similarity index 84% rename from src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLQueryContext.cs rename to src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs index b050ab970..f73f3ab6d 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLQueryContext.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs @@ -13,17 +13,22 @@ using Newtonsoft.Json.Linq; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets.Repositories; +using Squidex.Infrastructure.Commands; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { - public sealed class GraphQLQueryContext : QueryContext + public sealed class GraphQLExecutionContext : QueryContext { + public ICommandBus CommandBus { get; } + public IGraphQLUrlGenerator UrlGenerator { get; } - public GraphQLQueryContext(IAppEntity app, IAssetRepository assetRepository, IContentQueryService contentQuery, ClaimsPrincipal user, + public GraphQLExecutionContext(IAppEntity app, IAssetRepository assetRepository, ICommandBus commandBus, IContentQueryService contentQuery, ClaimsPrincipal user, IGraphQLUrlGenerator urlGenerator) : base(app, assetRepository, contentQuery, user) { + CommandBus = commandBus; + UrlGenerator = urlGenerator; } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs index 8ac7242bf..0c8082a31 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs @@ -24,15 +24,17 @@ using GraphQLSchema = GraphQL.Types.Schema; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { - public sealed class GraphQLModel : IGraphQLContext + public sealed class GraphQLModel : IGraphModel { private readonly Dictionary> fieldInfos; - private readonly Dictionary schemaTypes = new Dictionary(); + private readonly Dictionary inputFieldInfos; + private readonly Dictionary contentTypes = new Dictionary(); + private readonly Dictionary contentDataTypes = new Dictionary(); private readonly Dictionary schemas; private readonly PartitionResolver partitionResolver; private readonly IAppEntity app; - private readonly IGraphType assetType; private readonly IGraphType assetListType; + private readonly IComplexGraphType assetType; private readonly GraphQLSchema graphQLSchema; public bool CanGenerateAssetSourceUrl { get; } @@ -48,6 +50,42 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL assetType = new AssetGraphType(this); assetListType = new ListGraphType(new NonNullGraphType(assetType)); + inputFieldInfos = new Dictionary + { + { + typeof(StringField), + new StringGraphType() + }, + { + typeof(BooleanField), + new BooleanGraphType() + }, + { + typeof(NumberField), + new FloatGraphType() + }, + { + typeof(DateTimeField), + new DateGraphType() + }, + { + typeof(GeolocationField), + new GeolocationInputGraphType() + }, + { + typeof(TagsField), + new ListGraphType(new StringGraphType()) + }, + { + typeof(AssetsField), + new ListGraphType(new GuidGraphType()) + }, + { + typeof(ReferencesField), + new ListGraphType(new GuidGraphType()) + } + }; + fieldInfos = new Dictionary> { { @@ -70,14 +108,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL typeof(JsonField), field => ResolveDefault("Json") }, - { - typeof(TagsField), - field => ResolveDefault("String") - }, { typeof(GeolocationField), field => ResolveDefault("Geolocation") }, + { + typeof(TagsField), + field => ResolveDefault("String") + }, { typeof(AssetsField), field => ResolveAssets(assetListType) @@ -90,11 +128,19 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL this.schemas = schemas.ToDictionary(x => x.Id); - graphQLSchema = new GraphQLSchema { Query = new AppQueriesGraphType(this, this.schemas.Values) }; + var m = new AppMutationsGraphType(this, this.schemas.Values); + var q = new AppQueriesGraphType(this, this.schemas.Values); + + graphQLSchema = new GraphQLSchema { Query = q, Mutation = m }; - foreach (var schemaType in schemaTypes.Values) + foreach (var kvp in contentDataTypes) { - schemaType.Initialize(); + kvp.Value.Initialize(this, kvp.Key); + } + + foreach (var kvp in contentTypes) + { + kvp.Value.Initialize(this, kvp.Key, contentDataTypes[kvp.Key]); } } @@ -107,7 +153,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { var resolver = new FuncFieldResolver(c => { - var context = (GraphQLQueryContext)c.UserContext; + var context = (GraphQLExecutionContext)c.UserContext; return context.UrlGenerator.GenerateAssetUrl(app, c.Source); }); @@ -119,7 +165,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { var resolver = new FuncFieldResolver(c => { - var context = (GraphQLQueryContext)c.UserContext; + var context = (GraphQLExecutionContext)c.UserContext; return context.UrlGenerator.GenerateAssetSourceUrl(app, c.Source); }); @@ -131,7 +177,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { var resolver = new FuncFieldResolver(c => { - var context = (GraphQLQueryContext)c.UserContext; + var context = (GraphQLExecutionContext)c.UserContext; return context.UrlGenerator.GenerateAssetThumbnailUrl(app, c.Source); }); @@ -143,7 +189,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { var resolver = new FuncFieldResolver(c => { - var context = (GraphQLQueryContext)c.UserContext; + var context = (GraphQLExecutionContext)c.UserContext; return context.UrlGenerator.GenerateContentUrl(app, schema, c.Source); }); @@ -155,7 +201,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { var resolver = new FuncFieldResolver(c => { - var context = (GraphQLQueryContext)c.UserContext; + var context = (GraphQLExecutionContext)c.UserContext; var contentIds = c.Source.GetOrDefault(c.FieldName); return context.GetReferencedAssetsAsync(contentIds); @@ -167,27 +213,28 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL private ValueTuple ResolveReferences(Field field) { var schemaId = ((ReferencesField)field).Properties.SchemaId; - var schemaType = GetSchemaType(schemaId); - if (schemaType == null) + var contentType = GetContentType(schemaId); + + if (contentType == null) { return (null, null); } var resolver = new FuncFieldResolver(c => { - var context = (GraphQLQueryContext)c.UserContext; + var context = (GraphQLExecutionContext)c.UserContext; var contentIds = c.Source.GetOrDefault(c.FieldName); return context.GetReferencedContentsAsync(schemaId, contentIds); }); - var schemaFieldType = new ListGraphType(new NonNullGraphType(GetSchemaType(schemaId))); + var schemaFieldType = new ListGraphType(new NonNullGraphType(contentType)); return (schemaFieldType, resolver); } - public async Task<(object Data, object[] Errors)> ExecuteAsync(GraphQLQueryContext context, GraphQLQuery query) + public async Task<(object Data, object[] Errors)> ExecuteAsync(GraphQLExecutionContext context, GraphQLQuery query) { Guard.NotNull(context, nameof(context)); @@ -208,7 +255,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL return partitionResolver(key); } - public IGraphType GetAssetType() + public IComplexGraphType GetAssetType() { return assetType; } @@ -218,11 +265,33 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL return fieldInfos[field.GetType()](field); } - public IGraphType GetSchemaType(Guid schemaId) + public IComplexGraphType GetContentDataType(Guid schemaId) { var schema = schemas.GetOrDefault(schemaId); - return schema != null ? schemaTypes.GetOrAdd(schemaId, k => new ContentGraphType(schema, this)) : null; + if (schema == null) + { + return null; + } + + return schema != null ? contentDataTypes.GetOrAdd(schema, s => new ContentDataGraphType()) : null; + } + + public IComplexGraphType GetContentType(Guid schemaId) + { + var schema = schemas.GetOrDefault(schemaId); + + if (schema == null) + { + return null; + } + + return contentTypes.GetOrAdd(schema, s => new ContentGraphType()); + } + + public IGraphType GetInputGraphType(Field field) + { + return inputFieldInfos.GetOrAddDefault(field.GetType()); } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphModel.cs similarity index 80% rename from src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLContext.cs rename to src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphModel.cs index 45e00873a..29834fa71 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLContext.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphModel.cs @@ -14,15 +14,17 @@ using Squidex.Domain.Apps.Entities.Schemas; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { - public interface IGraphQLContext + public interface IGraphModel { bool CanGenerateAssetSourceUrl { get; } IFieldPartitioning ResolvePartition(Partitioning key); - IGraphType GetAssetType(); + IComplexGraphType GetAssetType(); - IGraphType GetSchemaType(Guid schemaId); + IComplexGraphType GetContentType(Guid schemaId); + + IComplexGraphType GetContentDataType(Guid schemaId); IFieldResolver ResolveAssetUrl(); @@ -32,6 +34,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL IFieldResolver ResolveContentUrl(ISchemaEntity schema); + IGraphType GetInputGraphType(Field field); + (IGraphType ResolveType, IFieldResolver Resolver) GetGraphType(Field field); } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppMutationsGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppMutationsGraphType.cs new file mode 100644 index 000000000..dc3ac8a4e --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppMutationsGraphType.cs @@ -0,0 +1,318 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using GraphQL.Resolvers; +using GraphQL.Types; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Entities.Contents.Commands; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types +{ + public sealed class AppMutationsGraphType : ObjectGraphType + { + public AppMutationsGraphType(IGraphModel model, IEnumerable schemas) + { + foreach (var schema in schemas) + { + var schemaId = schema.NamedId(); + var schemaType = schema.TypeName(); + var schemaName = schema.DisplayName(); + + var contentType = model.GetContentType(schema.Id); + var contentDataType = model.GetContentDataType(schema.Id); + + var inputType = new ContentDataGraphInputType(model, schema); + + AddContentCreate(schemaId, schemaType, schemaName, inputType, contentDataType, contentType); + AddContentUpdate(schemaId, schemaType, schemaName, inputType, contentDataType); + AddContentPatch(schemaId, schemaType, schemaName, inputType, contentDataType); + AddContentPublish(schemaId, schemaType, schemaName); + AddContentUnpublish(schemaId, schemaType, schemaName); + AddContentArchive(schemaId, schemaType, schemaName); + AddContentRestore(schemaId, schemaType, schemaName); + AddContentDelete(schemaId, schemaType, schemaName); + } + + Description = "The app mutations."; + } + + private void AddContentCreate(NamedId schemaId, string schemaType, string schemaName, ContentDataGraphInputType inputType, IComplexGraphType contentDataType, IComplexGraphType contentType) + { + AddField(new FieldType + { + Name = $"create{schemaType}Content", + Arguments = new QueryArguments + { + new QueryArgument(typeof(BooleanGraphType)) + { + Name = "publish", + Description = "Set to true to autopublish content.", + DefaultValue = false + }, + new QueryArgument(typeof(NoopGraphType)) + { + Name = "data", + Description = $"The data for the {schemaName} content.", + DefaultValue = null, + ResolvedType = new NonNullGraphType(inputType), + }, + new QueryArgument(typeof(IntGraphType)) + { + Name = "expectedVersion", + Description = "The expected version", + DefaultValue = EtagVersion.Any + } + }, + ResolvedType = new NonNullGraphType(contentType), + Resolver = ResolveAsync(async (c, publish) => + { + var argPublish = c.GetArgument("publish"); + + var contentData = GetContentData(c); + + var command = new CreateContent { SchemaId = schemaId, ContentId = Guid.NewGuid(), Data = contentData, Publish = argPublish }; + var commandContext = await publish(command); + + var result = commandContext.Result>(); + var response = ContentEntity.Create(command, result); + + return ContentEntity.Create(command, result); + }), + Description = $"Creates an {schemaName} content." + }); + } + + private void AddContentUpdate(NamedId schemaId, string schemaType, string schemaName, ContentDataGraphInputType inputType, IComplexGraphType contentDataType) + { + AddField(new FieldType + { + Name = $"update{schemaType}Content", + Arguments = new QueryArguments + { + new QueryArgument(typeof(NonNullGraphType)) + { + Name = "id", + Description = $"The id of the {schemaName} content (GUID)", + DefaultValue = string.Empty + }, + new QueryArgument(typeof(NoopGraphType)) + { + Name = "data", + Description = $"The data for the {schemaName} content.", + DefaultValue = null, + ResolvedType = new NonNullGraphType(inputType), + }, + new QueryArgument(typeof(IntGraphType)) + { + Name = "expectedVersion", + Description = "The expected version", + DefaultValue = EtagVersion.Any + } + }, + ResolvedType = new NonNullGraphType(contentDataType), + Resolver = ResolveAsync(async (c, publish) => + { + var contentId = c.GetArgument("id"); + var contentData = GetContentData(c); + + var command = new UpdateContent { SchemaId = schemaId, ContentId = contentId, Data = contentData }; + var commandContext = await publish(command); + + var result = commandContext.Result(); + + return result.Data; + }), + Description = $"Update an {schemaName} content by id." + }); + } + + private void AddContentPatch(NamedId schemaId, string schemaType, string schemaName, ContentDataGraphInputType inputType, IComplexGraphType contentDataType) + { + AddField(new FieldType + { + Name = $"patch{schemaType}Content", + Arguments = new QueryArguments + { + new QueryArgument(typeof(NonNullGraphType)) + { + Name = "id", + Description = $"The id of the {schemaName} content (GUID)", + DefaultValue = string.Empty + }, + new QueryArgument(typeof(NoopGraphType)) + { + Name = "data", + Description = $"The data for the {schemaName} content.", + DefaultValue = null, + ResolvedType = new NonNullGraphType(inputType), + }, + new QueryArgument(typeof(IntGraphType)) + { + Name = "expectedVersion", + Description = "The expected version", + DefaultValue = EtagVersion.Any + } + }, + ResolvedType = new NonNullGraphType(contentDataType), + Resolver = ResolveAsync(async (c, publish) => + { + var contentId = c.GetArgument("id"); + var contentData = GetContentData(c); + + var command = new PatchContent { SchemaId = schemaId, ContentId = contentId, Data = contentData }; + var commandContext = await publish(command); + + var result = commandContext.Result(); + + return result.Data; + }), + Description = $"Patch a {schemaName} content." + }); + } + + private void AddContentPublish(NamedId schemaId, string schemaType, string schemaName) + { + AddField(new FieldType + { + Name = $"publish{schemaType}Content", + Arguments = CreateIdArguments(schemaName), + ResolvedType = new NonNullGraphType(new CommandVersionGraphType()), + Resolver = ResolveAsync((c, publish) => + { + var contentId = c.GetArgument("id"); + + var command = new ChangeContentStatus { SchemaId = schemaId, ContentId = contentId, Status = Status.Published }; + + return publish(command); + }), + Description = $"Publish a {schemaName} content." + }); + } + + private void AddContentUnpublish(NamedId schemaId, string schemaType, string schemaName) + { + AddField(new FieldType + { + Name = $"unpublish{schemaType}Content", + Arguments = CreateIdArguments(schemaName), + ResolvedType = new NonNullGraphType(new CommandVersionGraphType()), + Resolver = ResolveAsync((c, publish) => + { + var contentId = c.GetArgument("id"); + + var command = new ChangeContentStatus { SchemaId = schemaId, ContentId = contentId, Status = Status.Draft }; + + return publish(command); + }), + Description = $"Unpublish a {schemaName} content." + }); + } + + private void AddContentArchive(NamedId schemaId, string schemaType, string schemaName) + { + AddField(new FieldType + { + Name = $"archive{schemaType}Content", + Arguments = CreateIdArguments(schemaName), + ResolvedType = new NonNullGraphType(new CommandVersionGraphType()), + Resolver = ResolveAsync((c, publish) => + { + var contentId = c.GetArgument("id"); + + var command = new ChangeContentStatus { SchemaId = schemaId, ContentId = contentId, Status = Status.Archived }; + + return publish(command); + }), + Description = $"Archive a {schemaName} content." + }); + } + + private void AddContentRestore(NamedId schemaId, string schemaType, string schemaName) + { + AddField(new FieldType + { + Name = $"restore{schemaType}Content", + Arguments = CreateIdArguments(schemaName), + ResolvedType = new NonNullGraphType(new CommandVersionGraphType()), + Resolver = ResolveAsync((c, publish) => + { + var contentId = c.GetArgument("id"); + + var command = new ChangeContentStatus { SchemaId = schemaId, ContentId = contentId, Status = Status.Draft }; + + return publish(command); + }), + Description = $"Restore a {schemaName} content." + }); + } + + private void AddContentDelete(NamedId schemaId, string schemaType, string schemaName) + { + AddField(new FieldType + { + Name = $"delete{schemaType}Content", + Arguments = CreateIdArguments(schemaName), + ResolvedType = new NonNullGraphType(new CommandVersionGraphType()), + Resolver = ResolveAsync((c, publish) => + { + var contentId = c.GetArgument("id"); + + var command = new DeleteContent { SchemaId = schemaId, ContentId = contentId }; + + return publish(command); + }), + Description = $"Delete an {schemaName} content." + }); + } + + private static QueryArguments CreateIdArguments(string schemaName) + { + return new QueryArguments + { + new QueryArgument(typeof(GuidGraphType)) + { + Name = "id", + Description = $"The id of the {schemaName} content (GUID)", + DefaultValue = string.Empty + }, + new QueryArgument(typeof(IntGraphType)) + { + Name = "expectedVersion", + Description = "The expected version", + DefaultValue = EtagVersion.Any + } + }; + } + + private static IFieldResolver ResolveAsync(Func>, Task> action) + { + return new FuncFieldResolver>(c => + { + var e = (GraphQLExecutionContext)c.UserContext; + + return action(c, command => + { + command.ExpectedVersion = c.GetArgument("expectedVersion"); + + return e.CommandBus.PublishAsync(command); + }); + }); + } + + private static NamedContentData GetContentData(ResolveFieldContext c) + { + return JObject.FromObject(c.GetArgument("data")).ToObject(); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppQueriesGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppQueriesGraphType.cs index 393dc904b..06734c014 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppQueriesGraphType.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppQueriesGraphType.cs @@ -8,29 +8,32 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using GraphQL.Resolvers; using GraphQL.Types; using Squidex.Domain.Apps.Entities.Schemas; -using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { public sealed class AppQueriesGraphType : ObjectGraphType { - public AppQueriesGraphType(IGraphQLContext ctx, IEnumerable schemas) + public AppQueriesGraphType(IGraphModel model, IEnumerable schemas) { - var assetType = ctx.GetAssetType(); + var assetType = model.GetAssetType(); AddAssetFind(assetType); AddAssetsQueries(assetType); foreach (var schema in schemas) { - var schemaName = schema.SchemaDef.Properties.Label.WithFallback(schema.SchemaDef.Name); - var schemaType = ctx.GetSchemaType(schema.Id); + var schemaId = schema.Id; + var schemaType = schema.TypeName(); + var schemaName = schema.DisplayName(); - AddContentFind(schema, schemaType, schemaName); - AddContentQueries(ctx, schema, schemaType, schemaName); + var contentType = model.GetContentType(schema.Id); + + AddContentFind(schemaId, schemaType, schemaName, contentType); + AddContentQueries(schemaId, schemaType, schemaName, contentType); } Description = "The app queries."; @@ -43,102 +46,94 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types Name = "findAsset", Arguments = CreateAssetFindArguments(), ResolvedType = assetType, - Resolver = new FuncFieldResolver(c => + Resolver = ResolveAsync((c, e) => { - var context = (GraphQLQueryContext)c.UserContext; - var contentId = Guid.Parse(c.GetArgument("id", Guid.Empty.ToString())); + var assetId = c.GetArgument("id"); - return context.FindAssetAsync(contentId); + return e.FindAssetAsync(assetId); }), Description = "Find an asset by id." }); } - private void AddContentFind(ISchemaEntity schema, IGraphType schemaType, string schemaName) + private void AddContentFind(Guid schemaId, string schemaType, string schemaName, IGraphType contentType) { AddField(new FieldType { - Name = $"find{schema.Name.ToPascalCase()}Content", + Name = $"find{schemaType}Content", Arguments = CreateContentFindTypes(schemaName), - ResolvedType = schemaType, - Resolver = new FuncFieldResolver(c => + ResolvedType = contentType, + Resolver = ResolveAsync((c, e) => { - var context = (GraphQLQueryContext)c.UserContext; - var contentId = Guid.Parse(c.GetArgument("id", Guid.Empty.ToString())); + var contentId = c.GetArgument("id"); - return context.FindContentAsync(schema.Id, contentId); + return e.FindContentAsync(schemaId, contentId); }), Description = $"Find an {schemaName} content by id." }); } - private void AddAssetsQueries(IGraphType assetType) + private void AddAssetsQueries(IComplexGraphType assetType) { AddField(new FieldType { Name = "queryAssets", Arguments = CreateAssetQueryArguments(), ResolvedType = new ListGraphType(new NonNullGraphType(assetType)), - Resolver = new FuncFieldResolver(c => + Resolver = ResolveAsync((c, e) => { - var context = (GraphQLQueryContext)c.UserContext; - - var argTop = c.GetArgument("top", 20); + var argTake = c.GetArgument("take", 20); var argSkip = c.GetArgument("skip", 0); var argQuery = c.GetArgument("search", string.Empty); - return context.QueryAssetsAsync(argQuery, argSkip, argTop); + return e.QueryAssetsAsync(argQuery, argSkip, argTake); }), - Description = "Query assets items." + Description = "Get assets." }); AddField(new FieldType { Name = "queryAssetsWithTotal", Arguments = CreateAssetQueryArguments(), - ResolvedType = new AssetResultGraphType(assetType), - Resolver = new FuncFieldResolver(c => + ResolvedType = new AssetsResultGraphType(assetType), + Resolver = ResolveAsync((c, e) => { - var context = (GraphQLQueryContext)c.UserContext; - - var argTop = c.GetArgument("top", 20); + var argTake = c.GetArgument("take", 20); var argSkip = c.GetArgument("skip", 0); var argQuery = c.GetArgument("search", string.Empty); - return context.QueryAssetsAsync(argQuery, argSkip, argTop); + return e.QueryAssetsAsync(argQuery, argSkip, argTake); }), - Description = "Query assets items with total count." + Description = "Get assets and total count." }); } - private void AddContentQueries(IGraphQLContext ctx, ISchemaEntity schema, IGraphType schemaType, string schemaName) + private void AddContentQueries(Guid schemaId, string schemaType, string schemaName, IComplexGraphType contentType) { AddField(new FieldType { - Name = $"query{schema.Name.ToPascalCase()}Contents", + Name = $"query{schemaType}Contents", Arguments = CreateContentQueryArguments(), - ResolvedType = new ListGraphType(new NonNullGraphType(schemaType)), - Resolver = new FuncFieldResolver(c => + ResolvedType = new ListGraphType(new NonNullGraphType(contentType)), + Resolver = ResolveAsync((c, e) => { - var context = (GraphQLQueryContext)c.UserContext; var contentQuery = BuildODataQuery(c); - return context.QueryContentsAsync(schema.Id.ToString(), contentQuery); + return e.QueryContentsAsync(schemaId.ToString(), contentQuery); }), Description = $"Query {schemaName} content items." }); AddField(new FieldType { - Name = $"query{schema.Name.ToPascalCase()}ContentsWithTotal", + Name = $"query{schemaType}ContentsWithTotal", Arguments = CreateContentQueryArguments(), - ResolvedType = new ContentResultGraphType(ctx, schema, schemaName), - Resolver = new FuncFieldResolver(c => + ResolvedType = new ContentsResultGraphType(schemaType, schemaName, contentType), + Resolver = ResolveAsync((c, e) => { - var context = (GraphQLQueryContext)c.UserContext; var contentQuery = BuildODataQuery(c); - return context.QueryContentsAsync(schema.Id.ToString(), contentQuery); + return e.QueryContentsAsync(schemaId.ToString(), contentQuery); }), Description = $"Query {schemaName} content items with total count." }); @@ -148,10 +143,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { return new QueryArguments { - new QueryArgument(typeof(StringGraphType)) + new QueryArgument(typeof(NonNullGraphType)) { Name = "id", - Description = "The id of the asset.", + Description = "The id of the asset (GUID).", DefaultValue = string.Empty } }; @@ -161,10 +156,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { return new QueryArguments { - new QueryArgument(typeof(StringGraphType)) + new QueryArgument(typeof(NonNullGraphType)) { Name = "id", - Description = $"The id of the {schemaName} content.", + Description = $"The id of the {schemaName} content (GUID)", DefaultValue = string.Empty } }; @@ -176,8 +171,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { new QueryArgument(typeof(IntGraphType)) { - Name = "top", - Description = "Optional number of assets to take.", + Name = "take", + Description = "Optional number of assets to take (Default: 20).", DefaultValue = 20 }, new QueryArgument(typeof(IntGraphType)) @@ -189,7 +184,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types new QueryArgument(typeof(StringGraphType)) { Name = "search", - Description = "Optional query.", + Description = "Optional query to limit the files by name.", DefaultValue = string.Empty } }; @@ -202,7 +197,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types new QueryArgument(typeof(IntGraphType)) { Name = "top", - Description = "Optional number of contents to take.", + Description = "Optional number of contents to take (Default: 20).", DefaultValue = 20 }, new QueryArgument(typeof(IntGraphType)) @@ -242,5 +237,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types return odataQuery; } + + private static IFieldResolver ResolveAsync(Func> action) + { + return new FuncFieldResolver>(c => + { + var e = (GraphQLExecutionContext)c.UserContext; + + return action(c, e); + }); + } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs index 7795a5a62..9c2ade9ab 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs @@ -15,145 +15,145 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { public sealed class AssetGraphType : ObjectGraphType { - public AssetGraphType(IGraphQLContext context) + public AssetGraphType(IGraphModel model) { Name = "AssetDto"; AddField(new FieldType { Name = "id", - Resolver = Resolver(x => x.Id.ToString()), ResolvedType = new NonNullGraphType(new StringGraphType()), + Resolver = Resolve(x => x.Id.ToString()), Description = "The id of the asset." }); AddField(new FieldType { Name = "version", - Resolver = Resolver(x => x.Version), ResolvedType = new NonNullGraphType(new IntGraphType()), + Resolver = Resolve(x => x.Version), Description = "The version of the asset." }); AddField(new FieldType { Name = "created", - Resolver = Resolver(x => x.Created.ToDateTimeUtc()), ResolvedType = new NonNullGraphType(new DateGraphType()), + Resolver = Resolve(x => x.Created.ToDateTimeUtc()), Description = "The date and time when the asset has been created." }); AddField(new FieldType { Name = "createdBy", - Resolver = Resolver(x => x.CreatedBy.ToString()), ResolvedType = new NonNullGraphType(new StringGraphType()), + Resolver = Resolve(x => x.CreatedBy.ToString()), Description = "The user that has created the asset." }); AddField(new FieldType { Name = "lastModified", - Resolver = Resolver(x => x.LastModified.ToDateTimeUtc()), ResolvedType = new NonNullGraphType(new DateGraphType()), + Resolver = Resolve(x => x.LastModified.ToDateTimeUtc()), Description = "The date and time when the asset has been modified last." }); AddField(new FieldType { Name = "lastModifiedBy", - Resolver = Resolver(x => x.LastModifiedBy.ToString()), ResolvedType = new NonNullGraphType(new StringGraphType()), + Resolver = Resolve(x => x.LastModifiedBy.ToString()), Description = "The user that has updated the asset last." }); AddField(new FieldType { Name = "mimeType", - Resolver = Resolver(x => x.MimeType), ResolvedType = new NonNullGraphType(new StringGraphType()), + Resolver = Resolve(x => x.MimeType), Description = "The mime type." }); AddField(new FieldType { Name = "url", - Resolver = context.ResolveAssetUrl(), ResolvedType = new NonNullGraphType(new StringGraphType()), + Resolver = model.ResolveAssetUrl(), Description = "The url to the asset." }); AddField(new FieldType { Name = "thumbnailUrl", - Resolver = context.ResolveAssetThumbnailUrl(), ResolvedType = new StringGraphType(), + Resolver = model.ResolveAssetThumbnailUrl(), Description = "The thumbnail url to the asset." }); AddField(new FieldType { Name = "fileName", - Resolver = Resolver(x => x.FileName), ResolvedType = new NonNullGraphType(new StringGraphType()), + Resolver = Resolve(x => x.FileName), Description = "The file name." }); AddField(new FieldType { Name = "fileType", - Resolver = Resolver(x => x.FileName.FileType()), ResolvedType = new NonNullGraphType(new StringGraphType()), + Resolver = Resolve(x => x.FileName.FileType()), Description = "The file type." }); AddField(new FieldType { Name = "fileSize", - Resolver = Resolver(x => x.FileSize), ResolvedType = new NonNullGraphType(new IntGraphType()), + Resolver = Resolve(x => x.FileSize), Description = "The size of the file in bytes." }); AddField(new FieldType { Name = "fileVersion", - Resolver = Resolver(x => x.FileVersion), ResolvedType = new NonNullGraphType(new IntGraphType()), + Resolver = Resolve(x => x.FileVersion), Description = "The version of the file." }); AddField(new FieldType { Name = "isImage", - Resolver = Resolver(x => x.IsImage), ResolvedType = new NonNullGraphType(new BooleanGraphType()), + Resolver = Resolve(x => x.IsImage), Description = "Determines of the created file is an image." }); AddField(new FieldType { Name = "pixelWidth", - Resolver = Resolver(x => x.PixelWidth), ResolvedType = new IntGraphType(), + Resolver = Resolve(x => x.PixelWidth), Description = "The width of the image in pixels if the asset is an image." }); AddField(new FieldType { Name = "pixelHeight", - Resolver = Resolver(x => x.PixelHeight), ResolvedType = new IntGraphType(), + Resolver = Resolve(x => x.PixelHeight), Description = "The height of the image in pixels if the asset is an image." }); - if (context.CanGenerateAssetSourceUrl) + if (model.CanGenerateAssetSourceUrl) { AddField(new FieldType { Name = "sourceUrl", - Resolver = context.ResolveAssetSourceUrl(), ResolvedType = new StringGraphType(), + Resolver = model.ResolveAssetSourceUrl(), Description = "The source url of the asset." }); } @@ -161,7 +161,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types Description = "An asset"; } - private static IFieldResolver Resolver(Func action) + private static IFieldResolver Resolve(Func action) { return new FuncFieldResolver(c => action(c.Source)); } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetResultGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetsResultGraphType.cs similarity index 70% rename from src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetResultGraphType.cs rename to src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetsResultGraphType.cs index d38478a33..1114e70a3 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetResultGraphType.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetsResultGraphType.cs @@ -13,30 +13,32 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { - public sealed class AssetResultGraphType : ObjectGraphType> + public sealed class AssetsResultGraphType : ObjectGraphType> { - public AssetResultGraphType(IGraphType assetType) + public AssetsResultGraphType(IComplexGraphType assetType) { Name = $"AssetResultDto"; AddField(new FieldType { Name = "total", - Resolver = Resolver(x => x.Total), + Resolver = Resolve(x => x.Total), ResolvedType = new NonNullGraphType(new IntGraphType()), - Description = $"The total number of asset." + Description = $"The total count of assets." }); AddField(new FieldType { Name = "items", - Resolver = Resolver(x => x), + Resolver = Resolve(x => x), ResolvedType = new ListGraphType(new NonNullGraphType(assetType)), Description = $"The assets." }); + + Description = "List of assets and total count of assets."; } - private static IFieldResolver Resolver(Func, object> action) + private static IFieldResolver Resolve(Func, object> action) { return new FuncFieldResolver, object>(c => action(c.Source)); } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/CommandVersionGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/CommandVersionGraphType.cs new file mode 100644 index 000000000..30fb044ea --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/CommandVersionGraphType.cs @@ -0,0 +1,44 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using GraphQL.Resolvers; +using GraphQL.Types; +using Squidex.Infrastructure.Commands; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types +{ + public sealed class CommandVersionGraphType : ComplexGraphType + { + public CommandVersionGraphType() + { + Name = "CommandVersionDto"; + + AddField(new FieldType + { + Name = "version", + ResolvedType = new IntGraphType(), + Resolver = ResolveEtag(), + Description = "The new version of the item." + }); + + Description = "The result of a mutation"; + } + + private static IFieldResolver ResolveEtag() + { + return new FuncFieldResolver(x => + { + if (x.Source.Result() is EntitySavedResult result) + { + return (int)result.Version; + } + + return null; + }); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphInputType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphInputType.cs new file mode 100644 index 000000000..440ea753a --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphInputType.cs @@ -0,0 +1,73 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Linq; +using GraphQL.Resolvers; +using GraphQL.Types; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types +{ + public sealed class ContentDataGraphInputType : InputObjectGraphType + { + public ContentDataGraphInputType(IGraphModel model, ISchemaEntity schema) + { + var schemaType = schema.TypeName(); + var schemaName = schema.DisplayName(); + + Name = $"{schemaType}InputDto"; + + foreach (var field in schema.SchemaDef.Fields.Where(x => !x.IsHidden)) + { + var inputType = model.GetInputGraphType(field); + + if (inputType != null) + { + if (field.RawProperties.IsRequired) + { + inputType = new NonNullGraphType(inputType); + } + + var fieldName = field.RawProperties.Label.WithFallback(field.Name); + + var fieldGraphType = new InputObjectGraphType + { + Name = $"{schemaType}Data{field.Name.ToPascalCase()}InputDto" + }; + + var partition = model.ResolvePartition(field.Partitioning); + + foreach (var partitionItem in partition) + { + fieldGraphType.AddField(new FieldType + { + Name = partitionItem.Key, + ResolvedType = inputType, + Resolver = null, + Description = field.RawProperties.Hints + }); + } + + fieldGraphType.Description = $"The input structure of the {fieldName} of a {schemaName} content type."; + + var fieldResolver = new FuncFieldResolver(c => c.Source.GetOrDefault(field.Name)); + + AddField(new FieldType + { + Name = field.Name.ToCamelCase(), + Resolver = fieldResolver, + ResolvedType = fieldGraphType + }); + } + } + + Description = $"The structure of a {schemaName} content type."; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphType.cs index f1eb1faba..b9822826b 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphType.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphType.cs @@ -9,22 +9,23 @@ using System.Linq; using GraphQL.Resolvers; using GraphQL.Types; using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; -using Schema = Squidex.Domain.Apps.Core.Schemas.Schema; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { public sealed class ContentDataGraphType : ObjectGraphType { - public ContentDataGraphType(Schema schema, IGraphQLContext qlContext) + public void Initialize(IGraphModel model, ISchemaEntity schema) { - var schemaName = schema.Properties.Label.WithFallback(schema.Name); + var schemaType = schema.TypeName(); + var schemaName = schema.DisplayName(); - Name = $"{schema.Name.ToPascalCase()}DataDto"; + Name = $"{schemaType}DataDto"; - foreach (var field in schema.Fields.Where(x => !x.IsHidden)) + foreach (var field in schema.SchemaDef.Fields.Where(x => !x.IsHidden)) { - var fieldInfo = qlContext.GetGraphType(field); + var fieldInfo = model.GetGraphType(field); if (fieldInfo.ResolveType != null) { @@ -32,10 +33,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types var fieldGraphType = new ObjectGraphType { - Name = $"{schema.Name.ToPascalCase()}Data{field.Name.ToPascalCase()}Dto" + Name = $"{schemaType}Data{field.Name.ToPascalCase()}Dto" }; - var partition = qlContext.ResolvePartition(field.Partitioning); + var partition = model.ResolvePartition(field.Partitioning); foreach (var partitionItem in partition) { diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs index f17cfa1a0..b8dc4b304 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs @@ -10,92 +10,81 @@ using System.Linq; using GraphQL.Resolvers; using GraphQL.Types; using Squidex.Domain.Apps.Entities.Schemas; -using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { public sealed class ContentGraphType : ObjectGraphType { - private readonly ISchemaEntity schema; - private readonly IGraphQLContext ctx; - - public ContentGraphType(ISchemaEntity schema, IGraphQLContext ctx) + public void Initialize(IGraphModel model, ISchemaEntity schema, IComplexGraphType contentDataType) { - this.ctx = ctx; - this.schema = schema; + var schemaType = schema.TypeName(); + var schemaName = schema.DisplayName(); - Name = $"{schema.Name.ToPascalCase()}Dto"; - } - - public void Initialize() - { - var schemaName = schema.SchemaDef.Properties.Label.WithFallback(schema.Name); + Name = $"{schemaType}Dto"; AddField(new FieldType { Name = "id", - Resolver = Resolver(x => x.Id.ToString()), ResolvedType = new NonNullGraphType(new StringGraphType()), + Resolver = Resolve(x => x.Id.ToString()), Description = $"The id of the {schemaName} content." }); AddField(new FieldType { Name = "version", - Resolver = Resolver(x => x.Version), ResolvedType = new NonNullGraphType(new IntGraphType()), + Resolver = Resolve(x => x.Version), Description = $"The version of the {schemaName} content." }); AddField(new FieldType { Name = "created", - Resolver = Resolver(x => x.Created.ToDateTimeUtc()), ResolvedType = new NonNullGraphType(new DateGraphType()), + Resolver = Resolve(x => x.Created.ToDateTimeUtc()), Description = $"The date and time when the {schemaName} content has been created." }); AddField(new FieldType { Name = "createdBy", - Resolver = Resolver(x => x.CreatedBy.ToString()), ResolvedType = new NonNullGraphType(new StringGraphType()), + Resolver = Resolve(x => x.CreatedBy.ToString()), Description = $"The user that has created the {schemaName} content." }); AddField(new FieldType { Name = "lastModified", - Resolver = Resolver(x => x.LastModified.ToDateTimeUtc()), ResolvedType = new NonNullGraphType(new DateGraphType()), + Resolver = Resolve(x => x.LastModified.ToDateTimeUtc()), Description = $"The date and time when the {schemaName} content has been modified last." }); AddField(new FieldType { Name = "lastModifiedBy", - Resolver = Resolver(x => x.LastModifiedBy.ToString()), ResolvedType = new NonNullGraphType(new StringGraphType()), + Resolver = Resolve(x => x.LastModifiedBy.ToString()), Description = $"The user that has updated the {schemaName} content last." }); AddField(new FieldType { Name = "url", - Resolver = ctx.ResolveContentUrl(schema), ResolvedType = new NonNullGraphType(new StringGraphType()), + Resolver = model.ResolveContentUrl(schema), Description = $"The url to the the {schemaName} content." }); - var dataType = new ContentDataGraphType(schema.SchemaDef, ctx); - - if (dataType.Fields.Any()) + if (contentDataType.Fields.Any()) { AddField(new FieldType { Name = "data", - Resolver = Resolver(x => x.Data), - ResolvedType = new NonNullGraphType(dataType), + ResolvedType = new NonNullGraphType(contentDataType), + Resolver = Resolve(x => x.Data), Description = $"The data of the {schemaName} content." }); } @@ -103,7 +92,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types Description = $"The structure of a {schemaName} content type."; } - private static IFieldResolver Resolver(Func action) + private static IFieldResolver Resolve(Func action) { return new FuncFieldResolver(c => action(c.Source)); } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentResultGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentsResultGraphType.cs similarity index 77% rename from src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentResultGraphType.cs rename to src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentsResultGraphType.cs index 1b6ebee7f..c8992e252 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentResultGraphType.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentsResultGraphType.cs @@ -8,18 +8,15 @@ using System; using GraphQL.Resolvers; using GraphQL.Types; -using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { - public sealed class ContentResultGraphType : ObjectGraphType> + public sealed class ContentsResultGraphType : ObjectGraphType> { - public ContentResultGraphType(IGraphQLContext ctx, ISchemaEntity schema, string schemaName) + public ContentsResultGraphType(string schemaType, string schemaName, IComplexGraphType contentType) { - Name = $"{schema.Name.ToPascalCase()}ResultDto"; - - var schemaType = ctx.GetSchemaType(schema.Id); + Name = $"{schemaType}ResultDto"; AddField(new FieldType { @@ -33,9 +30,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { Name = "items", Resolver = Resolver(x => x), - ResolvedType = new ListGraphType(new NonNullGraphType(schemaType)), + ResolvedType = new ListGraphType(new NonNullGraphType(contentType)), Description = $"The {schemaName} items." }); + + Description = $"List of {schemaName} items and total count."; } private static IFieldResolver Resolver(Func, object> action) diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/GeolocationInputGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/GeolocationInputGraphType.cs new file mode 100644 index 000000000..6de8c1405 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/GeolocationInputGraphType.cs @@ -0,0 +1,31 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using GraphQL.Types; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types +{ + public sealed class GeolocationInputGraphType : InputObjectGraphType + { + public GeolocationInputGraphType() + { + Name = "GeolocationInputDto"; + + AddField(new FieldType + { + Name = "latitude", + ResolvedType = new NonNullGraphType(new FloatGraphType()) + }); + + AddField(new FieldType + { + Name = "longitude", + ResolvedType = new NonNullGraphType(new FloatGraphType()) + }); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/GuidGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/GuidGraphType.cs new file mode 100644 index 000000000..196c8ed44 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/GuidGraphType.cs @@ -0,0 +1,55 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using GraphQL.Language.AST; +using GraphQL.Types; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types +{ + public sealed class GuidGraphType : ScalarGraphType + { + public GuidGraphType() + { + Name = "Guid"; + + Description = "The `Guid` scalar type global unique identifier"; + } + + public override object Serialize(object value) + { + return ParseValue(value)?.ToString(); + } + + public override object ParseValue(object value) + { + if (value is Guid guid) + { + return guid; + } + + var inputValue = value?.ToString().Trim('"'); + + if (Guid.TryParse(inputValue, out guid)) + { + return guid; + } + + return null; + } + + public override object ParseLiteral(IValue value) + { + if (value is StringValue stringValue) + { + return ParseValue(stringValue.Value); + } + + return null; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaExtensions.cs b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaExtensions.cs new file mode 100644 index 000000000..5d86e5682 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaExtensions.cs @@ -0,0 +1,41 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Schemas +{ + public static class SchemaExtensions + { + public static NamedId NamedId(this ISchemaEntity schema) + { + return new NamedId(schema.Id, schema.Name); + } + + public static string TypeName(this ISchemaEntity schema) + { + return schema.SchemaDef.Name.ToPascalCase(); + } + + public static string DisplayName(this ISchemaEntity schema) + { + return schema.SchemaDef.Properties.Label.WithFallback(schema.TypeName()); + } + + public static string TypeName(this Schema schema) + { + return schema.Name.ToPascalCase(); + } + + public static string DisplayName(this Schema schema) + { + return schema.Properties.Label.WithFallback(schema.TypeName()); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/SquidexCommand.cs b/src/Squidex.Domain.Apps.Entities/SquidexCommand.cs index a481289bf..a2c642e16 100644 --- a/src/Squidex.Domain.Apps.Entities/SquidexCommand.cs +++ b/src/Squidex.Domain.Apps.Entities/SquidexCommand.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Security.Claims; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; @@ -14,6 +15,8 @@ namespace Squidex.Domain.Apps.Entities { public RefToken Actor { get; set; } + public ClaimsPrincipal User { get; set; } + public long ExpectedVersion { get; set; } } } diff --git a/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoRepositoryBase.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoRepositoryBase.cs index b05f80515..27cfdca13 100644 --- a/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoRepositoryBase.cs +++ b/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoRepositoryBase.cs @@ -8,7 +8,6 @@ using System; using System.Globalization; using System.Threading.Tasks; -using MongoDB.Bson; using MongoDB.Driver; using Squidex.Infrastructure.Tasks; diff --git a/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs b/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs index 0f2da0d0f..8a1d43c1f 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs @@ -60,9 +60,9 @@ namespace Squidex.Areas.Api.Controllers.Assets /// /// The name of the app. /// The optional asset ids. - /// The number of assets to skip. - /// The number of assets to take (Default: 20). - /// The query to limit the files by name. + /// Optional number of assets to skip. + /// Optional number of assets to take (Default: 20). + /// Optional query to limit the files by name. /// Comma separated list of mime types to get. /// /// 200 => Assets returned. @@ -76,7 +76,7 @@ namespace Squidex.Areas.Api.Controllers.Assets [Route("apps/{app}/assets/")] [ProducesResponseType(typeof(AssetsDto), 200)] [ApiCosts(1)] - public async Task GetAssets(string app, [FromQuery] string query = null, [FromQuery] string mimeTypes = null, [FromQuery] string ids = null, [FromQuery] int skip = 0, [FromQuery] int take = 10) + public async Task GetAssets(string app, [FromQuery] string query = null, [FromQuery] string mimeTypes = null, [FromQuery] string ids = null, [FromQuery] int skip = 0, [FromQuery] int take = 20) { var mimeTypeList = new HashSet(); diff --git a/src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs b/src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs index e9861fe66..fb8c80634 100644 --- a/src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs @@ -90,7 +90,7 @@ namespace Squidex.Areas.Api.Controllers.Contents await contentQuery.QueryAsync(App, name, User, archived, idsList) : await contentQuery.QueryAsync(App, name, User, archived, Request.QueryString.ToString()); - var response = new AssetsDto + var response = new ContentsDto { Total = result.Contents.Total, Items = result.Contents.Take(200).Select(item => @@ -161,7 +161,7 @@ namespace Squidex.Areas.Api.Controllers.Contents { await contentQuery.FindSchemaAsync(App, name); - var command = new CreateContent { ContentId = Guid.NewGuid(), User = User, Data = request.ToCleaned(), Publish = publish }; + var command = new CreateContent { ContentId = Guid.NewGuid(), Data = request.ToCleaned(), Publish = publish }; var context = await CommandBus.PublishAsync(command); @@ -179,7 +179,7 @@ namespace Squidex.Areas.Api.Controllers.Contents { await contentQuery.FindSchemaAsync(App, name); - var command = new UpdateContent { ContentId = id, User = User, Data = request.ToCleaned() }; + var command = new UpdateContent { ContentId = id, Data = request.ToCleaned() }; var context = await CommandBus.PublishAsync(command); @@ -197,7 +197,7 @@ namespace Squidex.Areas.Api.Controllers.Contents { await contentQuery.FindSchemaAsync(App, name); - var command = new PatchContent { ContentId = id, User = User, Data = request.ToCleaned() }; + var command = new PatchContent { ContentId = id, Data = request.ToCleaned() }; var context = await CommandBus.PublishAsync(command); @@ -215,7 +215,7 @@ namespace Squidex.Areas.Api.Controllers.Contents { await contentQuery.FindSchemaAsync(App, name); - var command = new ChangeContentStatus { Status = Status.Published, ContentId = id, User = User }; + var command = new ChangeContentStatus { Status = Status.Published, ContentId = id }; await CommandBus.PublishAsync(command); @@ -230,7 +230,7 @@ namespace Squidex.Areas.Api.Controllers.Contents { await contentQuery.FindSchemaAsync(App, name); - var command = new ChangeContentStatus { Status = Status.Draft, ContentId = id, User = User }; + var command = new ChangeContentStatus { Status = Status.Draft, ContentId = id }; await CommandBus.PublishAsync(command); @@ -245,7 +245,7 @@ namespace Squidex.Areas.Api.Controllers.Contents { await contentQuery.FindSchemaAsync(App, name); - var command = new ChangeContentStatus { Status = Status.Archived, ContentId = id, User = User }; + var command = new ChangeContentStatus { Status = Status.Archived, ContentId = id }; await CommandBus.PublishAsync(command); @@ -260,7 +260,7 @@ namespace Squidex.Areas.Api.Controllers.Contents { await contentQuery.FindSchemaAsync(App, name); - var command = new ChangeContentStatus { Status = Status.Draft, ContentId = id, User = User }; + var command = new ChangeContentStatus { Status = Status.Draft, ContentId = id }; await CommandBus.PublishAsync(command); @@ -275,7 +275,7 @@ namespace Squidex.Areas.Api.Controllers.Contents { await contentQuery.FindSchemaAsync(App, name); - var command = new DeleteContent { ContentId = id, User = User }; + var command = new DeleteContent { ContentId = id }; await CommandBus.PublishAsync(command); diff --git a/src/Squidex/Areas/Api/Controllers/Content/Generator/SchemaSwaggerGenerator.cs b/src/Squidex/Areas/Api/Controllers/Content/Generator/SchemaSwaggerGenerator.cs index 9236c96d5..010f411c0 100644 --- a/src/Squidex/Areas/Api/Controllers/Content/Generator/SchemaSwaggerGenerator.cs +++ b/src/Squidex/Areas/Api/Controllers/Content/Generator/SchemaSwaggerGenerator.cs @@ -14,6 +14,7 @@ using Squidex.Config; using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.GenerateJsonSchema; using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; using Squidex.Pipeline.Swagger; using Squidex.Shared.Identity; @@ -32,7 +33,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator private readonly JsonSchema4 dataSchema; private readonly string schemaPath; private readonly string schemaName; - private readonly string schemaKey; + private readonly string schemaType; private readonly string appPath; static SchemaSwaggerGenerator() @@ -68,12 +69,12 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator appPath = path; schemaPath = schema.Name; - schemaName = schema.Properties.Label.WithFallback(schema.Name); - schemaKey = schema.Name.ToPascalCase(); + schemaName = schema.DisplayName(); + schemaType = schema.TypeName(); - dataSchema = schemaResolver($"{schemaKey}Dto", schema.BuildJsonSchema(partitionResolver, schemaResolver)); + dataSchema = schemaResolver($"{schemaType}Dto", schema.BuildJsonSchema(partitionResolver, schemaResolver)); - contentSchema = schemaResolver($"{schemaKey}ContentDto", schemaBuilder.CreateContentSchema(schema, dataSchema)); + contentSchema = schemaResolver($"{schemaType}ContentDto", schemaBuilder.CreateContentSchema(schema, dataSchema)); } public void GenerateSchemaOperations() @@ -108,13 +109,13 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator { return AddOperation(SwaggerOperationMethod.Get, null, $"{appPath}/{schemaPath}", operation => { - operation.OperationId = $"Query{schemaKey}Contents"; + operation.OperationId = $"Query{schemaType}Contents"; operation.Summary = $"Queries {schemaName} contents."; operation.Security = ReaderSecurity; operation.Description = SchemaQueryDescription; - operation.AddQueryParameter("$top", JsonObjectType.Number, "Optional number of contents to take."); + operation.AddQueryParameter("$top", JsonObjectType.Number, "Optional number of contents to take (Default: 20)."); operation.AddQueryParameter("$skip", JsonObjectType.Number, "Optional number of contents to skip."); operation.AddQueryParameter("$filter", JsonObjectType.String, "Optional OData filter."); operation.AddQueryParameter("$search", JsonObjectType.String, "Optional OData full text search."); @@ -128,7 +129,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator { return AddOperation(SwaggerOperationMethod.Get, schemaName, $"{appPath}/{schemaPath}/{{id}}", operation => { - operation.OperationId = $"Get{schemaKey}Content"; + operation.OperationId = $"Get{schemaType}Content"; operation.Summary = $"Get a {schemaName} content."; operation.Security = ReaderSecurity; @@ -140,7 +141,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator { return AddOperation(SwaggerOperationMethod.Post, null, $"{appPath}/{schemaPath}", operation => { - operation.OperationId = $"Create{schemaKey}Content"; + operation.OperationId = $"Create{schemaType}Content"; operation.Summary = $"Create a {schemaName} content."; operation.Security = EditorSecurity; @@ -155,7 +156,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator { return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appPath}/{schemaPath}/{{id}}", operation => { - operation.OperationId = $"Update{schemaKey}Content"; + operation.OperationId = $"Update{schemaType}Content"; operation.Summary = $"Update a {schemaName} content."; operation.Security = EditorSecurity; @@ -169,8 +170,8 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator { return AddOperation(SwaggerOperationMethod.Patch, schemaName, $"{appPath}/{schemaPath}/{{id}}", operation => { - operation.OperationId = $"Path{schemaKey}Content"; - operation.Summary = $"Patchs a {schemaName} content."; + operation.OperationId = $"Path{schemaType}Content"; + operation.Summary = $"Patch a {schemaName} content."; operation.Security = EditorSecurity; operation.AddBodyParameter("data", dataSchema, SchemaBodyDescription); @@ -183,7 +184,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator { return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appPath}/{schemaPath}/{{id}}/publish", operation => { - operation.OperationId = $"Publish{schemaKey}Content"; + operation.OperationId = $"Publish{schemaType}Content"; operation.Summary = $"Publish a {schemaName} content."; operation.Security = EditorSecurity; @@ -195,7 +196,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator { return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appPath}/{schemaPath}/{{id}}/unpublish", operation => { - operation.OperationId = $"Unpublish{schemaKey}Content"; + operation.OperationId = $"Unpublish{schemaType}Content"; operation.Summary = $"Unpublish a {schemaName} content."; operation.Security = EditorSecurity; @@ -207,7 +208,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator { return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appPath}/{schemaPath}/{{id}}/archive", operation => { - operation.OperationId = $"Archive{schemaKey}Content"; + operation.OperationId = $"Archive{schemaType}Content"; operation.Summary = $"Archive a {schemaName} content."; operation.Security = EditorSecurity; @@ -219,7 +220,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator { return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appPath}/{schemaPath}/{{id}}/restore", operation => { - operation.OperationId = $"Restore{schemaKey}Content"; + operation.OperationId = $"Restore{schemaType}Content"; operation.Summary = $"Restore a {schemaName} content."; operation.Security = EditorSecurity; @@ -231,7 +232,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator { return AddOperation(SwaggerOperationMethod.Delete, schemaName, $"{appPath}/{schemaPath}/{{id}}/", operation => { - operation.OperationId = $"Delete{schemaKey}Content"; + operation.OperationId = $"Delete{schemaType}Content"; operation.Summary = $"Delete a {schemaName} content."; operation.Security = EditorSecurity; diff --git a/src/Squidex/Areas/Api/Controllers/Content/Models/AssetsDto.cs b/src/Squidex/Areas/Api/Controllers/Content/Models/ContentsDto.cs similarity index 95% rename from src/Squidex/Areas/Api/Controllers/Content/Models/AssetsDto.cs rename to src/Squidex/Areas/Api/Controllers/Content/Models/ContentsDto.cs index 6be8cbf15..12c19cd9c 100644 --- a/src/Squidex/Areas/Api/Controllers/Content/Models/AssetsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Content/Models/ContentsDto.cs @@ -7,7 +7,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models { - public sealed class AssetsDto + public sealed class ContentsDto { /// /// The total number of content items. diff --git a/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithActorCommandMiddleware.cs b/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithActorCommandMiddleware.cs index 28741a599..ce1b72ced 100644 --- a/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithActorCommandMiddleware.cs +++ b/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithActorCommandMiddleware.cs @@ -27,13 +27,21 @@ namespace Squidex.Pipeline.CommandMiddlewares public Task HandleAsync(CommandContext context, Func next) { - if (context.Command is SquidexCommand squidexCommand && squidexCommand.Actor == null) + if (context.Command is SquidexCommand squidexCommand) { - var actorToken = - FindActorFromSubject() ?? - FindActorFromClient(); + if (squidexCommand.Actor == null) + { + var actorToken = + FindActorFromSubject() ?? + FindActorFromClient(); - squidexCommand.Actor = actorToken ?? throw new SecurityException("No actor with subject or client id available."); + squidexCommand.Actor = actorToken ?? throw new SecurityException("No actor with subject or client id available."); + } + + if (squidexCommand.User == null) + { + squidexCommand.User = httpContextAccessor.HttpContext.User; + } } return next(); diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLMutationTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLMutationTests.cs new file mode 100644 index 000000000..509ba6db0 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLMutationTests.cs @@ -0,0 +1,207 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using FakeItEasy; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Entities.Contents.Commands; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL +{ + public class GraphQLMutationTests : GraphQLTestBase + { + private readonly CommandContext commandContext = new CommandContext(new PatchContent()); + + public GraphQLMutationTests() + { + A.CallTo(() => commandBus.PublishAsync(A.Ignored)) + .Returns(commandContext); + } + + [Fact] + public async Task Should_return_single_content_when_patching_content() + { + var contentId = Guid.NewGuid(); + var content = CreateContent(contentId, Guid.Empty, Guid.Empty); + + var query = $@" + mutation OP($data: MySchemaInputDto!) {{ + patchMySchemaContent(id: ""{contentId}"", data: $data) {{ + myString {{ + de + }} + myNumber {{ + iv + }} + myBoolean {{ + iv + }} + myDatetime {{ + iv + }} + myJson {{ + iv + }} + myGeolocation {{ + iv + }} + myTags {{ + iv + }} + }} + }}"; + + commandContext.Complete(new ContentDataChangedResult(content.Data, 1)); + + var camelContent = new NamedContentData(); + + foreach (var kvp in content.Data) + { + if (kvp.Key != "my-json") + { + camelContent[kvp.Key.ToCamelCase()] = kvp.Value; + } + } + + var variables = + new JObject( + new JProperty("data", JObject.FromObject(camelContent))); + + var result = await sut.QueryAsync(app, user, new GraphQLQuery { Query = query, Variables = variables }); + + var expected = new + { + data = new + { + patchMySchemaContent = new + { + myString = new + { + de = "value" + }, + myNumber = new + { + iv = 1 + }, + myBoolean = new + { + iv = true + }, + myDatetime = new + { + iv = content.LastModified.ToDateTimeUtc() + }, + myJson = new + { + iv = new + { + value = 1 + } + }, + myGeolocation = new + { + iv = new + { + latitude = 10, + longitude = 20 + } + }, + myTags = new + { + iv = new[] + { + "tag1", + "tag2" + } + } + } + } + }; + + AssertResult(expected, result); + } + + [Fact] + public async Task Should_publish_command_for_restore() + { + var contentId = Guid.NewGuid(); + + var query = $@" + mutation {{ + restoreMySchemaContent(id: ""{contentId}"") {{ + version + }} + }}"; + + commandContext.Complete(new EntitySavedResult(13)); + + var result = await sut.QueryAsync(app, user, new GraphQLQuery { Query = query }); + + var expected = new + { + data = new + { + restoreMySchemaContent = new + { + version = 13 + } + } + }; + + AssertResult(expected, result); + + A.CallTo(() => commandBus.PublishAsync( + A.That.Matches(x => + x.SchemaId.Equals(schema.NamedId()) && + x.ContentId == contentId && + x.Status == Status.Draft))) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_publish_command_for_delete() + { + var contentId = Guid.NewGuid(); + + var query = $@" + mutation {{ + deleteMySchemaContent(id: ""{contentId}"") {{ + version + }} + }}"; + + commandContext.Complete(new EntitySavedResult(13)); + + var result = await sut.QueryAsync(app, user, new GraphQLQuery { Query = query }); + + var expected = new + { + data = new + { + deleteMySchemaContent = new + { + version = 13 + } + } + }; + + AssertResult(expected, result); + + A.CallTo(() => commandBus.PublishAsync( + A.That.Matches(x => + x.SchemaId.Equals(schema.NamedId()) && + x.ContentId == contentId))) + .MustHaveHappened(); + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs similarity index 77% rename from tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTests.cs rename to tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs index d5f7e6176..5b6d937a2 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs @@ -7,87 +7,17 @@ using System; using System.Collections.Generic; -using System.Security.Claims; using System.Threading.Tasks; using FakeItEasy; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Options; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using NodaTime.Extensions; -using Squidex.Domain.Apps.Core; -using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.Contents; -using Squidex.Domain.Apps.Core.Schemas; -using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Assets; -using Squidex.Domain.Apps.Entities.Assets.Repositories; -using Squidex.Domain.Apps.Entities.Contents.TestData; -using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; using Xunit; -#pragma warning disable SA1311 // Static readonly fields must begin with upper-case letter - namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { - public class GraphQLTests + public class GraphQLQueriesTests : GraphQLTestBase { - private static readonly Guid schemaId = Guid.NewGuid(); - private static readonly Guid appId = Guid.NewGuid(); - private static readonly string appName = "my-app"; - private readonly Schema schemaDef; - private readonly IContentQueryService contentQuery = A.Fake(); - private readonly IAssetRepository assetRepository = A.Fake(); - private readonly ISchemaEntity schema = A.Fake(); - private readonly IMemoryCache cache = new MemoryCache(Options.Create(new MemoryCacheOptions())); - private readonly IAppProvider appProvider = A.Fake(); - private readonly IAppEntity app = A.Dummy(); - private readonly ClaimsPrincipal user = new ClaimsPrincipal(); - private readonly IGraphQLService sut; - - public GraphQLTests() - { - schemaDef = - new Schema("my-schema") - .AddField(new JsonField(1, "my-json", Partitioning.Invariant, - new JsonFieldProperties())) - .AddField(new StringField(2, "my-string", Partitioning.Language, - new StringFieldProperties())) - .AddField(new NumberField(3, "my-number", Partitioning.Invariant, - new NumberFieldProperties())) - .AddField(new AssetsField(4, "my-assets", Partitioning.Invariant, - new AssetsFieldProperties())) - .AddField(new BooleanField(5, "my-boolean", Partitioning.Invariant, - new BooleanFieldProperties())) - .AddField(new DateTimeField(6, "my-datetime", Partitioning.Invariant, - new DateTimeFieldProperties())) - .AddField(new ReferencesField(7, "my-references", Partitioning.Invariant, - new ReferencesFieldProperties { SchemaId = schemaId })) - .AddField(new ReferencesField(9, "my-invalid", Partitioning.Invariant, - new ReferencesFieldProperties { SchemaId = Guid.NewGuid() })) - .AddField(new GeolocationField(10, "my-geolocation", Partitioning.Invariant, - new GeolocationFieldProperties())) - .AddField(new TagsField(11, "my-tags", Partitioning.Invariant, - new TagsFieldProperties())); - - A.CallTo(() => app.Id).Returns(appId); - A.CallTo(() => app.Name).Returns(appName); - A.CallTo(() => app.LanguagesConfig).Returns(LanguagesConfig.Build(Language.DE)); - - A.CallTo(() => schema.Id).Returns(schemaId); - A.CallTo(() => schema.Name).Returns(schemaDef.Name); - A.CallTo(() => schema.SchemaDef).Returns(schemaDef); - A.CallTo(() => schema.IsPublished).Returns(true); - A.CallTo(() => schema.ScriptQuery).Returns(""); - - var allSchemas = new List { schema }; - - A.CallTo(() => appProvider.GetSchemasAsync(appId)).Returns(allSchemas); - - sut = new CachingGraphQLService(cache, appProvider, assetRepository, contentQuery, new FakeUrlGenerator()); - } - [Theory] [InlineData(null)] [InlineData("")] @@ -103,7 +33,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } }; - AssertJson(expected, new { data = result.Data }); + AssertResult(expected, result); } [Fact] @@ -111,7 +41,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { const string query = @" query { - queryAssets(search: ""my-query"", top: 30, skip: 5) { + queryAssets(search: ""my-query"", take: 30, skip: 5) { id version created @@ -169,7 +99,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } }; - AssertJson(expected, new { data = result.Data }); + AssertResult(expected, result); } [Fact] @@ -177,7 +107,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { const string query = @" query { - queryAssetsWithTotal(search: ""my-query"", top: 30, skip: 5) { + queryAssetsWithTotal(search: ""my-query"", take: 30, skip: 5) { total items { id @@ -242,7 +172,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } }; - AssertJson(expected, new { data = result.Data }); + AssertResult(expected, result); } [Fact] @@ -304,7 +234,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } }; - AssertJson(expected, new { data = result.Data }); + AssertResult(expected, result); } [Fact] @@ -417,7 +347,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } }; - AssertJson(expected, new { data = result.Data }); + AssertResult(expected, result); } [Fact] @@ -537,7 +467,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } }; - AssertJson(expected, new { data = result.Data }); + AssertResult(expected, result); } [Fact] @@ -646,7 +576,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } }; - AssertJson(expected, new { data = result.Data }); + AssertResult(expected, result); } [Fact] @@ -706,7 +636,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } }; - AssertJson(expected, new { data = result.Data }); + AssertResult(expected, result); } [Fact] @@ -766,7 +696,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } }; - AssertJson(expected, new { data = result.Data }); + AssertResult(expected, result); } [Fact] @@ -803,78 +733,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL data = (object)null }; - AssertJson(expected, new { data = result.Data }); - } - - private static IContentEntity CreateContent(Guid id, Guid refId, Guid assetId, NamedContentData data = null) - { - var now = DateTime.UtcNow.ToInstant(); - - data = data ?? - new NamedContentData() - .AddField("my-json", - new ContentFieldData().AddValue("iv", JToken.FromObject(new { value = 1 }))) - .AddField("my-string", - new ContentFieldData().AddValue("de", "value")) - .AddField("my-assets", - new ContentFieldData().AddValue("iv", JToken.FromObject(new[] { assetId }))) - .AddField("my-number", - new ContentFieldData().AddValue("iv", 1)) - .AddField("my-boolean", - new ContentFieldData().AddValue("iv", true)) - .AddField("my-datetime", - new ContentFieldData().AddValue("iv", now.ToDateTimeUtc())) - .AddField("my-tags", - new ContentFieldData().AddValue("iv", JToken.FromObject(new[] { "tag1", "tag2" }))) - .AddField("my-references", - new ContentFieldData().AddValue("iv", JToken.FromObject(new[] { refId }))) - .AddField("my-geolocation", - new ContentFieldData().AddValue("iv", JToken.FromObject(new { latitude = 10, longitude = 20 }))); - - var content = new FakeContentEntity - { - Id = id, - Version = 1, - Created = now, - CreatedBy = new RefToken("subject", "user1"), - LastModified = now, - LastModifiedBy = new RefToken("subject", "user2"), - Data = data - }; - - return content; - } - - private static IAssetEntity CreateAsset(Guid id) - { - var now = DateTime.UtcNow.ToInstant(); - - var asset = new FakeAssetEntity - { - Id = id, - Version = 1, - Created = now, - CreatedBy = new RefToken("subject", "user1"), - LastModified = now, - LastModifiedBy = new RefToken("subject", "user2"), - FileName = "MyFile.png", - FileSize = 1024, - FileVersion = 123, - MimeType = "image/png", - IsImage = true, - PixelWidth = 800, - PixelHeight = 600 - }; - - return asset; - } - - private static void AssertJson(object expected, object result) - { - var resultJson = JsonConvert.SerializeObject(result, Formatting.Indented); - var expectJson = JsonConvert.SerializeObject(expected, Formatting.Indented); - - Assert.Equal(expectJson, resultJson); + AssertResult(expected, result, false); } } } diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs new file mode 100644 index 000000000..d7a74e669 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs @@ -0,0 +1,169 @@ +// ========================================================================== +// 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.Security.Claims; +using FakeItEasy; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NodaTime.Extensions; +using Squidex.Domain.Apps.Core; +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Domain.Apps.Entities.Assets.Repositories; +using Squidex.Domain.Apps.Entities.Contents.TestData; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Xunit; + +#pragma warning disable SA1311 // Static readonly fields must begin with upper-case letter +#pragma warning disable SA1401 // Fields must be private + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL +{ + public class GraphQLTestBase + { + protected static readonly Guid schemaId = Guid.NewGuid(); + protected static readonly Guid appId = Guid.NewGuid(); + protected static readonly string appName = "my-app"; + protected readonly Schema schemaDef; + protected readonly IContentQueryService contentQuery = A.Fake(); + protected readonly ICommandBus commandBus = A.Fake(); + protected readonly IAssetRepository assetRepository = A.Fake(); + protected readonly ISchemaEntity schema = A.Fake(); + protected readonly IMemoryCache cache = new MemoryCache(Options.Create(new MemoryCacheOptions())); + protected readonly IAppProvider appProvider = A.Fake(); + protected readonly IAppEntity app = A.Dummy(); + protected readonly ClaimsPrincipal user = new ClaimsPrincipal(); + protected readonly IGraphQLService sut; + + public GraphQLTestBase() + { + schemaDef = + new Schema("my-schema") + .AddField(new JsonField(1, "my-json", Partitioning.Invariant, + new JsonFieldProperties())) + .AddField(new StringField(2, "my-string", Partitioning.Language, + new StringFieldProperties())) + .AddField(new NumberField(3, "my-number", Partitioning.Invariant, + new NumberFieldProperties())) + .AddField(new AssetsField(4, "my-assets", Partitioning.Invariant, + new AssetsFieldProperties())) + .AddField(new BooleanField(5, "my-boolean", Partitioning.Invariant, + new BooleanFieldProperties())) + .AddField(new DateTimeField(6, "my-datetime", Partitioning.Invariant, + new DateTimeFieldProperties())) + .AddField(new ReferencesField(7, "my-references", Partitioning.Invariant, + new ReferencesFieldProperties { SchemaId = schemaId })) + .AddField(new ReferencesField(9, "my-invalid", Partitioning.Invariant, + new ReferencesFieldProperties { SchemaId = Guid.NewGuid() })) + .AddField(new GeolocationField(10, "my-geolocation", Partitioning.Invariant, + new GeolocationFieldProperties())) + .AddField(new TagsField(11, "my-tags", Partitioning.Invariant, + new TagsFieldProperties())); + + A.CallTo(() => app.Id).Returns(appId); + A.CallTo(() => app.Name).Returns(appName); + A.CallTo(() => app.LanguagesConfig).Returns(LanguagesConfig.Build(Language.DE)); + + A.CallTo(() => schema.Id).Returns(schemaId); + A.CallTo(() => schema.Name).Returns(schemaDef.Name); + A.CallTo(() => schema.SchemaDef).Returns(schemaDef); + A.CallTo(() => schema.IsPublished).Returns(true); + A.CallTo(() => schema.ScriptQuery).Returns(""); + + var allSchemas = new List { schema }; + + A.CallTo(() => appProvider.GetSchemasAsync(appId)).Returns(allSchemas); + + sut = new CachingGraphQLService(cache, appProvider, assetRepository, commandBus, contentQuery, new FakeUrlGenerator()); + } + + protected static IContentEntity CreateContent(Guid id, Guid refId, Guid assetId, NamedContentData data = null) + { + var now = DateTime.UtcNow.ToInstant(); + + data = data ?? + new NamedContentData() + .AddField("my-json", + new ContentFieldData().AddValue("iv", JToken.FromObject(new { value = 1 }))) + .AddField("my-string", + new ContentFieldData().AddValue("de", "value")) + .AddField("my-assets", + new ContentFieldData().AddValue("iv", JToken.FromObject(new[] { assetId }))) + .AddField("my-number", + new ContentFieldData().AddValue("iv", 1)) + .AddField("my-boolean", + new ContentFieldData().AddValue("iv", true)) + .AddField("my-datetime", + new ContentFieldData().AddValue("iv", now.ToDateTimeUtc())) + .AddField("my-tags", + new ContentFieldData().AddValue("iv", JToken.FromObject(new[] { "tag1", "tag2" }))) + .AddField("my-references", + new ContentFieldData().AddValue("iv", JToken.FromObject(new[] { refId }))) + .AddField("my-geolocation", + new ContentFieldData().AddValue("iv", JToken.FromObject(new { latitude = 10, longitude = 20 }))); + + var content = new ContentEntity + { + Id = id, + Version = 1, + Created = now, + CreatedBy = new RefToken("subject", "user1"), + LastModified = now, + LastModifiedBy = new RefToken("subject", "user2"), + Data = data + }; + + return content; + } + + protected static IAssetEntity CreateAsset(Guid id) + { + var now = DateTime.UtcNow.ToInstant(); + + var asset = new FakeAssetEntity + { + Id = id, + Version = 1, + Created = now, + CreatedBy = new RefToken("subject", "user1"), + LastModified = now, + LastModifiedBy = new RefToken("subject", "user2"), + FileName = "MyFile.png", + FileSize = 1024, + FileVersion = 123, + MimeType = "image/png", + IsImage = true, + PixelWidth = 800, + PixelHeight = 600 + }; + + return asset; + } + + protected static void AssertResult(object expected, (object Data, object[] Errors) result, bool checkErrors = true) + { + if (checkErrors && (result.Errors != null && result.Errors.Length > 0)) + { + throw new InvalidOperationException(result.Errors[0]?.ToString()); + } + + var resultJson = JsonConvert.SerializeObject(new { data = result.Data }, Formatting.Indented); + var expectJson = JsonConvert.SerializeObject(expected, Formatting.Indented); + + Assert.Equal(expectJson, resultJson); + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeContentEntity.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeContentEntity.cs deleted file mode 100644 index c7f0d9af8..000000000 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeContentEntity.cs +++ /dev/null @@ -1,35 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using NodaTime; -using Squidex.Domain.Apps.Core.Contents; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Entities.Contents.TestData -{ - public sealed class FakeContentEntity : IContentEntity - { - public Guid Id { get; set; } - - public Guid AppId { get; set; } - - public long Version { get; set; } - - public Instant Created { get; set; } - - public Instant LastModified { get; set; } - - public RefToken CreatedBy { get; set; } - - public RefToken LastModifiedBy { get; set; } - - public NamedContentData Data { get; set; } - - public Status Status { get; set; } - } -}