From 9cabed3c905feecf0427d7e19f1c0484dada0e9a Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sun, 19 Mar 2017 20:16:28 +0100 Subject: [PATCH] Some progress with asset management API --- Nuget.config | 6 + src/Squidex.Events/Assets/AssetCreated.cs | 6 + src/Squidex.Events/Assets/AssetEvent.cs | 3 +- src/Squidex.Events/Assets/AssetUpdated.cs | 22 ++++ .../Images/IAssetStorage.cs | 21 ++++ .../Images/IThumbnailGenerator.cs | 18 +++ .../ImageSharpThumbnailGenerator.cs | 46 ++++++++ .../Images/Physical/PhysicalAssetStorage.cs | 84 +++++++++++++ .../Squidex.Infrastructure.csproj | 4 + .../Assets/MongoAssetEntity.cs | 41 +++++++ .../Assets/MongoAssetRepository.cs | 78 +++++++++++++ .../MongoAssetRepository_EventHandling.cs | 50 ++++++++ src/Squidex.Read/Assets/IAssetEntity.cs | 19 +++ .../Assets/Repositories/IAssetRepository.cs | 23 ++++ src/Squidex.Write/Assets/AssetDomainObject.cs | 24 ++-- .../Assets/Commands/CreateAsset.cs | 19 ++- .../Controllers/Api/Assets/AssetConfig.cs | 15 +++ .../Api/Assets/AssetsController.cs | 110 ++++++++++++++++++ 18 files changed, 564 insertions(+), 25 deletions(-) create mode 100644 Nuget.config create mode 100644 src/Squidex.Events/Assets/AssetUpdated.cs create mode 100644 src/Squidex.Infrastructure/Images/IAssetStorage.cs create mode 100644 src/Squidex.Infrastructure/Images/IThumbnailGenerator.cs create mode 100644 src/Squidex.Infrastructure/Images/ImageSharp/ImageSharpThumbnailGenerator.cs create mode 100644 src/Squidex.Infrastructure/Images/Physical/PhysicalAssetStorage.cs create mode 100644 src/Squidex.Read.MongoDb/Assets/MongoAssetEntity.cs create mode 100644 src/Squidex.Read.MongoDb/Assets/MongoAssetRepository.cs create mode 100644 src/Squidex.Read.MongoDb/Assets/MongoAssetRepository_EventHandling.cs create mode 100644 src/Squidex.Read/Assets/IAssetEntity.cs create mode 100644 src/Squidex.Read/Assets/Repositories/IAssetRepository.cs create mode 100644 src/Squidex/Controllers/Api/Assets/AssetConfig.cs create mode 100644 src/Squidex/Controllers/Api/Assets/AssetsController.cs diff --git a/Nuget.config b/Nuget.config new file mode 100644 index 000000000..b1738fc41 --- /dev/null +++ b/Nuget.config @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/Squidex.Events/Assets/AssetCreated.cs b/src/Squidex.Events/Assets/AssetCreated.cs index 377609575..b673adb28 100644 --- a/src/Squidex.Events/Assets/AssetCreated.cs +++ b/src/Squidex.Events/Assets/AssetCreated.cs @@ -14,5 +14,11 @@ namespace Squidex.Events.Assets public class AssetCreated : AssetEvent { public string Name { get; set; } + + public string MimeType { get; set; } + + public long FileSize { get; set; } + + public bool IsImage { get; set; } } } diff --git a/src/Squidex.Events/Assets/AssetEvent.cs b/src/Squidex.Events/Assets/AssetEvent.cs index f09c85a94..9fb442919 100644 --- a/src/Squidex.Events/Assets/AssetEvent.cs +++ b/src/Squidex.Events/Assets/AssetEvent.cs @@ -7,11 +7,10 @@ // ========================================================================== using System; -using Squidex.Infrastructure.CQRS.Events; namespace Squidex.Events.Assets { - public abstract class AssetEvent : IEvent + public abstract class AssetEvent : AppEvent { public Guid AssetId { get; set; } } diff --git a/src/Squidex.Events/Assets/AssetUpdated.cs b/src/Squidex.Events/Assets/AssetUpdated.cs new file mode 100644 index 000000000..4e2bf8677 --- /dev/null +++ b/src/Squidex.Events/Assets/AssetUpdated.cs @@ -0,0 +1,22 @@ +// ========================================================================== +// AssetUpdated.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Infrastructure; + +namespace Squidex.Events.Assets +{ + [TypeName("AssetUpdated")] + public class AssetUpdated : AssetEvent + { + public string MimeType { get; set; } + + public long FileSize { get; set; } + + public bool IsImage { get; set; } + } +} diff --git a/src/Squidex.Infrastructure/Images/IAssetStorage.cs b/src/Squidex.Infrastructure/Images/IAssetStorage.cs new file mode 100644 index 000000000..0a5dfdf33 --- /dev/null +++ b/src/Squidex.Infrastructure/Images/IAssetStorage.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// IAssetStorage.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.IO; +using System.Threading.Tasks; + +namespace Squidex.Infrastructure.Images +{ + public interface IAssetStorage + { + Task GetAssetAsync(Guid id, string tags = null); + + Task UploadAssetAsync(Guid id, Stream stream, string tags = null); + } +} \ No newline at end of file diff --git a/src/Squidex.Infrastructure/Images/IThumbnailGenerator.cs b/src/Squidex.Infrastructure/Images/IThumbnailGenerator.cs new file mode 100644 index 000000000..66b22eceb --- /dev/null +++ b/src/Squidex.Infrastructure/Images/IThumbnailGenerator.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// IThumbnailGenerator.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.IO; +using System.Threading.Tasks; + +namespace Squidex.Infrastructure.Images +{ + public interface IThumbnailGenerator + { + Task GetThumbnailOrNullAsync(Stream input, int dimension); + } +} diff --git a/src/Squidex.Infrastructure/Images/ImageSharp/ImageSharpThumbnailGenerator.cs b/src/Squidex.Infrastructure/Images/ImageSharp/ImageSharpThumbnailGenerator.cs new file mode 100644 index 000000000..54c0af791 --- /dev/null +++ b/src/Squidex.Infrastructure/Images/ImageSharp/ImageSharpThumbnailGenerator.cs @@ -0,0 +1,46 @@ +// ========================================================================== +// ImageSharpThumbnailGenerator.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.IO; +using System.Threading.Tasks; +using ImageSharp; +using ImageSharp.Formats; +using ImageSharp.Processing; + +namespace Squidex.Infrastructure.Images.ImageSharp +{ + public sealed class ImageSharpThumbnailGenerator : IThumbnailGenerator + { + public ImageSharpThumbnailGenerator() + { + Configuration.Default.AddImageFormat(new JpegFormat()); + Configuration.Default.AddImageFormat(new PngFormat()); + } + + public Task GetThumbnailOrNullAsync(Stream input, int dimension) + { + return Task.Run(() => + { + var result = new MemoryStream(); + + var options = + new ResizeOptions + { + Size = new Size(dimension, dimension), + Mode = ResizeMode.Max + }; + + var image = new Image(input).Resize(options); + + image.Save(result); + + return result; + }); + } + } +} diff --git a/src/Squidex.Infrastructure/Images/Physical/PhysicalAssetStorage.cs b/src/Squidex.Infrastructure/Images/Physical/PhysicalAssetStorage.cs new file mode 100644 index 000000000..fe3fe89f1 --- /dev/null +++ b/src/Squidex.Infrastructure/Images/Physical/PhysicalAssetStorage.cs @@ -0,0 +1,84 @@ +// ========================================================================== +// PhysicalAssetStorage.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.IO; +using System.Threading.Tasks; + +namespace Squidex.Infrastructure.Images.Physical +{ + public sealed class PhysicalAssetStorage : IAssetStorage, IExternalSystem + { + private readonly DirectoryInfo directory; + + public PhysicalAssetStorage(string path) + { + Guard.NotNullOrEmpty(path, nameof(path)); + + directory = new DirectoryInfo(path); + } + + public void Connect() + { + try + { + if (!directory.Exists) + { + directory.Create(); + } + } + catch + { + if (!directory.Exists) + { + throw new ConfigurationException($"Cannot access directory {directory.FullName}"); + } + } + } + + public Task GetAssetAsync(Guid id, string tags = null) + { + var file = GetFile(id, tags); + + Stream stream = null; + + if (file.Exists) + { + stream = file.OpenRead(); + } + + return Task.FromResult(stream); + } + + public async Task UploadAssetAsync(Guid id, Stream stream, string tags = null) + { + var file = GetFile(id, tags); + + using (var fileStream = file.OpenWrite()) + { + await stream.CopyToAsync(fileStream); + } + } + + private FileInfo GetFile(Guid id, string tags) + { + var fileName = id.ToString(); + + if (!string.IsNullOrWhiteSpace(tags)) + { + fileName += tags; + } + + Guard.ValidFileName(fileName, tags); + + var file = new FileInfo(Path.Combine(directory.FullName, fileName)); + + return file; + } + } +} diff --git a/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj b/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj index b9208aeb0..42928d91f 100644 --- a/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj +++ b/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj @@ -7,6 +7,10 @@ True + + + + diff --git a/src/Squidex.Read.MongoDb/Assets/MongoAssetEntity.cs b/src/Squidex.Read.MongoDb/Assets/MongoAssetEntity.cs new file mode 100644 index 000000000..69584033f --- /dev/null +++ b/src/Squidex.Read.MongoDb/Assets/MongoAssetEntity.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Text; +using MongoDB.Bson.Serialization.Attributes; +using Squidex.Infrastructure; +using Squidex.Infrastructure.MongoDb; +using Squidex.Read.Assets; + +namespace Squidex.Read.MongoDb.Assets +{ + public sealed class MongoAssetEntity : MongoEntity, IAssetEntity + { + [BsonRequired] + [BsonElement] + public string MimeType { get; set; } + + [BsonRequired] + [BsonElement] + public string Name { get; set; } + + [BsonRequired] + [BsonElement] + public long FileSize { get; set; } + + [BsonRequired] + [BsonElement] + public long Version { get; set; } + + [BsonRequired] + [BsonElement] + public Guid AppId { get; set; } + + [BsonRequired] + [BsonElement] + public RefToken CreatedBy { get; set; } + + [BsonRequired] + [BsonElement] + public RefToken LastModifiedBy { get; set; } + } +} diff --git a/src/Squidex.Read.MongoDb/Assets/MongoAssetRepository.cs b/src/Squidex.Read.MongoDb/Assets/MongoAssetRepository.cs new file mode 100644 index 000000000..ebd1755c8 --- /dev/null +++ b/src/Squidex.Read.MongoDb/Assets/MongoAssetRepository.cs @@ -0,0 +1,78 @@ +// ========================================================================== +// MongoAssetRepository.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using MongoDB.Bson; +using MongoDB.Driver; +using Squidex.Infrastructure.MongoDb; +using Squidex.Read.Assets; +using Squidex.Read.Assets.Repositories; + +namespace Squidex.Read.MongoDb.Assets +{ + public partial class MongoAssetRepository : MongoRepositoryBase, IAssetRepository + { + public MongoAssetRepository(IMongoDatabase database) + : base(database) + { + } + + public async Task> QueryAsync(Guid appId, HashSet mimeTypes = null, string query = null, int take = 10, int skip = 0) + { + var filter = CreateFilter(appId, mimeTypes, query); + + var assets = + await Collection.Find(filter).Skip(skip).Limit(take).ToListAsync(); + + return assets.OfType().ToList(); + } + + public async Task CountAsync(Guid appId, HashSet mimeTypes = null, string query = null) + { + var filter = CreateFilter(appId, mimeTypes, query); + + var count = + await Collection.Find(filter).CountAsync(); + + return count; + } + + public async Task FindAssetAsync(Guid id) + { + var entity = + await Collection.Find(s => s.Id == id).FirstOrDefaultAsync(); + + return entity; + } + + private static FilterDefinition CreateFilter(Guid appId, HashSet mimeTypes, string query) + { + var filters = new List> + { + Filter.Eq(x => x.AppId, appId) + }; + + if (mimeTypes != null && mimeTypes.Count > 0) + { + filters.Add(Filter.In(x => x.MimeType, mimeTypes)); + } + + if (!string.IsNullOrWhiteSpace(query)) + { + filters.Add(Filter.Regex(x => x.Name, new BsonRegularExpression(query, "i"))); + } + + var filter = Filter.And(filters); + + return filter; + } + } +} diff --git a/src/Squidex.Read.MongoDb/Assets/MongoAssetRepository_EventHandling.cs b/src/Squidex.Read.MongoDb/Assets/MongoAssetRepository_EventHandling.cs new file mode 100644 index 000000000..ec2c5acc3 --- /dev/null +++ b/src/Squidex.Read.MongoDb/Assets/MongoAssetRepository_EventHandling.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using Squidex.Events.Apps; +using Squidex.Events.Assets; +using Squidex.Infrastructure.CQRS.Events; +using Squidex.Infrastructure.Dispatching; +using Squidex.Infrastructure.Reflection; +using Squidex.Read.MongoDb.Utils; + +namespace Squidex.Read.MongoDb.Assets +{ + public partial class MongoAssetRepository + { + public Task On(Envelope @event) + { + return this.DispatchActionAsync(@event.Payload, @event.Headers); + } + + protected Task On(AssetCreated @event, EnvelopeHeaders headers) + { + return Collection.CreateAsync(@event, headers, a => + { + SimpleMapper.Map(@event, a); + }); + } + + protected Task On(AssetUpdated @event, EnvelopeHeaders headers) + { + return Collection.UpdateAsync(@event, headers, a => + { + SimpleMapper.Map(@event, a); + }); + } + + protected Task On(AssetRenamed @event, EnvelopeHeaders headers) + { + return Collection.UpdateAsync(@event, headers, a => + { + SimpleMapper.Map(@event, a); + }); + } + + protected Task On(AssetDeleted @event, EnvelopeHeaders headers) + { + return Collection.DeleteOneAsync(Filter.Eq(x => x.Id, @event.AssetId)); + } + } +} diff --git a/src/Squidex.Read/Assets/IAssetEntity.cs b/src/Squidex.Read/Assets/IAssetEntity.cs new file mode 100644 index 000000000..0dd5a9260 --- /dev/null +++ b/src/Squidex.Read/Assets/IAssetEntity.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// IAssetEntity.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Read.Assets +{ + public interface IAssetEntity : IAppRefEntity, IEntityWithCreatedBy, IEntityWithLastModifiedBy, IEntityWithVersion + { + string MimeType { get; } + + string Name { get; } + + long FileSize { get; } + } +} diff --git a/src/Squidex.Read/Assets/Repositories/IAssetRepository.cs b/src/Squidex.Read/Assets/Repositories/IAssetRepository.cs new file mode 100644 index 000000000..f01676bc2 --- /dev/null +++ b/src/Squidex.Read/Assets/Repositories/IAssetRepository.cs @@ -0,0 +1,23 @@ +// ========================================================================== +// IAssetRepository.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Squidex.Read.Assets.Repositories +{ + public interface IAssetRepository + { + Task> QueryAsync(Guid appId, HashSet mimeTypes = null, string query = null, int take = 10, int skip = 0); + + Task CountAsync(Guid appId, HashSet mimeTypes = null, string query = null); + + Task FindAssetAsync(Guid id); + } +} diff --git a/src/Squidex.Write/Assets/AssetDomainObject.cs b/src/Squidex.Write/Assets/AssetDomainObject.cs index fffa687fe..7fec488ea 100644 --- a/src/Squidex.Write/Assets/AssetDomainObject.cs +++ b/src/Squidex.Write/Assets/AssetDomainObject.cs @@ -15,21 +15,23 @@ using Squidex.Infrastructure.Dispatching; using Squidex.Infrastructure.Reflection; using Squidex.Write.Assets.Commands; +// ReSharper disable UnusedParameter.Local + namespace Squidex.Write.Assets { - public class AssetDomainObject : DomainObject + public class AssetDomainObject : DomainObjectBase { private bool isDeleted; - private string name; + private string fileName; public bool IsDeleted { get { return isDeleted; } } - public string Name + public string FileName { - get { return name; } + get { return fileName; } } public AssetDomainObject(Guid id, int version) @@ -39,12 +41,12 @@ namespace Squidex.Write.Assets protected void On(AssetCreated @event) { - name = @event.Name; + fileName = @event.Name; } protected void On(AssetRenamed @event) { - name = @event.Name; + fileName = @event.Name; } protected void On(AssetDeleted @event) @@ -54,11 +56,11 @@ namespace Squidex.Write.Assets public AssetDomainObject Create(CreateAsset command) { - Guard.Valid(command, nameof(command), () => "Cannot create content"); + Guard.NotNull(command, nameof(command)); VerifyNotCreated(); - RaiseEvent(SimpleMapper.Map(command, new AssetCreated())); + RaiseEvent(SimpleMapper.Map(command, new AssetCreated { Name = command.FileName })); return this; } @@ -88,7 +90,7 @@ namespace Squidex.Write.Assets private void VerifyDifferentNames(string newName, Func message) { - if (string.Equals(name, newName)) + if (string.Equals(fileName, newName)) { throw new ValidationException(message(), new ValidationError("The asset already has this name.", "Name")); } @@ -96,7 +98,7 @@ namespace Squidex.Write.Assets private void VerifyNotCreated() { - if (!string.IsNullOrWhiteSpace(name)) + if (!string.IsNullOrWhiteSpace(fileName)) { throw new DomainException("Asset has already been created."); } @@ -104,7 +106,7 @@ namespace Squidex.Write.Assets private void VerifyCreatedAndNotDeleted() { - if (isDeleted || !string.IsNullOrWhiteSpace(name)) + if (isDeleted || !string.IsNullOrWhiteSpace(fileName)) { throw new DomainException("Asset has already been deleted or not created yet."); } diff --git a/src/Squidex.Write/Assets/Commands/CreateAsset.cs b/src/Squidex.Write/Assets/Commands/CreateAsset.cs index 74b9af323..ef6977e30 100644 --- a/src/Squidex.Write/Assets/Commands/CreateAsset.cs +++ b/src/Squidex.Write/Assets/Commands/CreateAsset.cs @@ -6,21 +6,16 @@ // All rights reserved. // ========================================================================== -using System.Collections.Generic; -using Squidex.Infrastructure; - namespace Squidex.Write.Assets.Commands { - public sealed class CreateAsset : AssetAggregateCommand, IValidatable + public sealed class CreateAsset : AssetAggregateCommand { - public string Name { get; set; } + public string FileName { get; set; } + + public string MimeType { get; set; } + + public long FileSize { get; set; } - public void Validate(IList errors) - { - if (!Name.IsSlug()) - { - errors.Add(new ValidationError("Name must be a valid slug", nameof(Name))); - } - } + public bool IsImage { get; set; } } } diff --git a/src/Squidex/Controllers/Api/Assets/AssetConfig.cs b/src/Squidex/Controllers/Api/Assets/AssetConfig.cs new file mode 100644 index 000000000..1e4ec5a14 --- /dev/null +++ b/src/Squidex/Controllers/Api/Assets/AssetConfig.cs @@ -0,0 +1,15 @@ +// ========================================================================== +// AssetConfig.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Controllers.Api.Assets +{ + public sealed class AssetConfig + { + public long MaxSize { get; set; } = 5 * 1024 * 1024; + } +} diff --git a/src/Squidex/Controllers/Api/Assets/AssetsController.cs b/src/Squidex/Controllers/Api/Assets/AssetsController.cs new file mode 100644 index 000000000..6d7bca1a8 --- /dev/null +++ b/src/Squidex/Controllers/Api/Assets/AssetsController.cs @@ -0,0 +1,110 @@ +// ========================================================================== +// AssetsController.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.IO; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using NSwag.Annotations; +using Squidex.Core.Identity; +using Squidex.Infrastructure; +using Squidex.Infrastructure.CQRS.Commands; +using Squidex.Infrastructure.Images; +using Squidex.Pipeline; +using Squidex.Write.Assets.Commands; + +namespace Squidex.Controllers.Api.Assets +{ + /// + /// Uploads and retrieves assets. + /// + [Authorize(Roles = SquidexRoles.AppEditor)] + [ApiExceptionFilter] + [ServiceFilter(typeof(AppFilterAttribute))] + [SwaggerTag("Assets")] + public class AssetsController : ControllerBase + { + private readonly IAssetStorage assetStorage; + private readonly AssetConfig assetsConfig; + private readonly IThumbnailGenerator thumbnailGenerator; + + public AssetsController( + ICommandBus commandBus, + IAssetStorage assetStorage, + IThumbnailGenerator thumbnailGenerator, + IOptions assetsConfig) + : base(commandBus) + { + this.assetStorage = assetStorage; + this.assetsConfig = assetsConfig.Value; + this.thumbnailGenerator = thumbnailGenerator; + } + + /// + /// Creates and uploads a new asset. + /// + /// The name of the app. + /// The name of the schema. + /// + /// 201 => Asset created. + /// 404 => App not found. + /// 400 => Asset exceeds the maximum size. + /// + [HttpPost] + [Route("apps/{app}/schemas/{name}/fields/")] + [ProducesResponseType(typeof(EntityCreatedDto), 201)] + [ProducesResponseType(typeof(ErrorDto), 409)] + [ProducesResponseType(typeof(ErrorDto), 400)] + public async Task PostAsset(string app, IFormFile file) + { + if (file.Length > assetsConfig.MaxSize) + { + var error = new ValidationError($"File size cannot be longer than ${assetsConfig.MaxSize}."); + + throw new ValidationException("Cannot create asset.", error); + } + + var command = new CreateAsset + { + AssetId = Guid.NewGuid(), + FileSize = file.Length, + FileName = file.Name, + MimeType = file.ContentType + }; + + var fileContent = new MemoryStream(); + + await file.CopyToAsync(fileContent); + + fileContent.Position = 0; + + var fileThumbnail = await thumbnailGenerator.GetThumbnailOrNullAsync(fileContent, 200); + + if (fileThumbnail != null) + { + command.IsImage = true; + + await assetStorage.UploadAssetAsync(command.AssetId, fileThumbnail, "thumbnail"); + } + + fileContent.Position = 0; + + await assetStorage.UploadAssetAsync(command.AssetId, fileContent); + + var context = await CommandBus.PublishAsync(command); + + var result = context.Result>().IdOrValue; + var response = new EntityCreatedDto { Id = result.ToString() }; + + return StatusCode(201, response); + } + } +}