diff --git a/.dockerignore b/.dockerignore index 9b8b49c2a..5206962bb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,6 +9,7 @@ # Build results **/bin/ +**/build/ **/obj/ **/publish/ @@ -18,7 +19,7 @@ # NodeJS **/node_modules/ -# Scripts (should be copied from node_modules on build) -**/wwwroot/scripts/**/*.* +**/src/Squidex/Assets/*.* -**/src/Squidex/appsettings.Development.json \ No newline at end of file +**/src/Squidex/appsettings.Development.json +**/src/Squidex/Properties/launchSettings.json \ No newline at end of file diff --git a/.drone.yml b/.drone.yml index 389a52a4a..c49d6846a 100644 --- a/.drone.yml +++ b/.drone.yml @@ -1,3 +1,8 @@ +clone: + git: + image: plugins/git:next + pull: true + pipeline: test_pull_request: image: docker @@ -43,9 +48,9 @@ pipeline: image: plugins/slack template: > {{#success build.status}} - build {{build.number}} succeeded. Good job. + Squidex build {{build.number}} succeeded. Good job. {{else}} - build {{build.number}} failed. Fix me please. + Squidex build {{build.number}} failed. Fix me please. {{/success}} secrets: [ slack_webhook ] when: diff --git a/.gitignore b/.gitignore index c2f3a2d05..3631589b0 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,8 @@ .vscode # Build results -build/ bin/ +build/ obj/ publish/ @@ -23,4 +23,7 @@ node_modules/ /src/Squidex/appsettings.Development.json /src/Squidex/Assets -/src/Squidex/Properties/launchSettings.json \ No newline at end of file +/src/Squidex/package-lock.json +/src/Squidex/Properties/launchSettings.json + +/global.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 30180d367..ac5184aec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,119 @@ # Changelog +## v1.11.0 - 2018-09-24 + +### Features + +* **API**: Correct handling of `If-None-Match` header to support caching. +* **Rules**: Major refactoring of action handlers to add new actions with less code. +* **Rules**: Twitter action to post status update. +* **Rules**: Prerender.io action to invalidate cache entries for SPA sites. +* **Contents**: Support IN-queries, like `fileName in ['Logo.jpg', 'Logo.png']` +* **UI**: Cloning content items. +* **UI**: Tag input in header to filter by tags when assigning assets to content. +* **Schemas**: Color picker as additional editor for string fields. +* **Statistics**: Report api usage and performance per client. + +### Bugfixes + +* **Clustering / Orleans**: Fixed correct serialization of exceptions, e.g. as validation errors. +* **Backups**: Always assign the user who started the restore operation as Owner to the app. +* **UI**: Reset name when a asset or content query is saved. +* **UI**: Disable spellchecking for tag editor. + +## v1.10.0 - 2018-08-29 + +### Featues + +* **Contents**: Introduce `X-Unpublished` header to also get unpublished content. +* **UI**: General feature to store UI settings. +* **UI**: Save content queries. +* **UI**: Save assets queries. +* **GraphQL**: Endpoint to run multiple queries in parallel with a single request. + +## v1.9.0 - 2018-08-19 + +### Features + +* **Scripting**: Override for the slugify method to use single line characters when replacing diacritics. +* **Docker**: Smaller image size. + +## v1.8.0 - 2018-06-30 + +### Features + +* **Schemas**: Singleton schemas (can only have single content) + +### Bugfixes + +* **UI**: Nested fields got wrong ids and names and could not be saved. +* **Content**: Ensure that the content api returns content in correct order when querying by ids. + +## v1.7.0 - 2018-06-25 + +* Migration to .NET Core 2.1 + +## v1.6.2 - 2018-06-23 + +### Features + +* **UI**: Better sortable with improved UX. +* **Migration**: Increased performance. +* **Migration**: Disable event handlers during migration. + +### Bugfixes + +* **Schemas**: Invariant name handling for field names. + +## v1.6.1 - 2018-06-22 + +### Bugfixes + +* **MongoDB**: Fixed date time handling. + +## v1.6.0 - 2018-06-07 + +### Features + +* **Schemas**: Nested Schemas. +* **UI**: Migration to RxJS6. +* **UI**: Migration to Angular6. + +## v1.5.0 - 2018-05-20 + +### Bugfixes + +* **UI**: Fixed the pattern selector in field editor. + +### Features + +* **Content**: Allow to save content updates as draft. +* **Schemas**: Create folders to group schemas. +* **UI**: Increased the search input. +* **UI**: Plugin system for content editors. + +## v1.4.1 - 2018-05-02 + +### Bugfixes + +* **Orleans**: Remove orleans dashboard from 8080. + +## v1.4.0 - 2018-05-02 + +### Features + +* **UI**: Big refactorings and UI improvements. +* **Actions**: New log formatter with placeholder for user infos. +* **Actions**: Azure Queue action. +* **Actions**: Algolia action. +* **Actions**: Fastly action. +* **Backup**: Backup all your data to an archive. + ## v1.3.0 - 2018-02-17 ### Features -* **Actions**: ElasticSearch action +* **Actions**: ElasticSearch action. ### Refactorings diff --git a/Dockerfile b/Dockerfile index 3895f5fcf..1a5abe4c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # # Stage 1, Prebuild # -FROM squidex/aspnetcore-build-phantomjs:2.0.3-jessie as builder +FROM squidex/dotnet:2.1-sdk-chromium-phantomjs-node as builder COPY src/Squidex/package.json /tmp/package.json @@ -24,24 +24,29 @@ RUN dotnet restore \ && dotnet test tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj \ && dotnet test tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj \ && dotnet test tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj \ - && dotnet test tests/Squidex.Domain.Users.Tests/Squidex.Domain.Users.Tests.csproj + && dotnet test tests/Squidex.Domain.Users.Tests/Squidex.Domain.Users.Tests.csproj \ + && dotnet test tests/Squidex.Tests/Squidex.Tests.csproj # Publish -RUN dotnet publish src/Squidex/Squidex.csproj --output /out/ --configuration Release +RUN dotnet publish src/Squidex/Squidex.csproj --output /out/alpine --configuration Release -r alpine.3.7-x64 # # Stage 2, Build runtime # -FROM microsoft/aspnetcore:2.0.3-jessie +FROM microsoft/dotnet:2.1-runtime-deps-alpine # Default AspNetCore directory WORKDIR /app -# Copy from nuild stage -COPY --from=builder /out/ . +# add libuv +RUN apk add --no-cache libuv \ + && ln -s /usr/lib/libuv.so.1 /usr/lib/libuv.so + +# Copy from build stage +COPY --from=builder /out/alpine . EXPOSE 80 EXPOSE 33333 EXPOSE 40000 -ENTRYPOINT ["dotnet", "Squidex.dll"] \ No newline at end of file +ENTRYPOINT ["./Squidex"] \ No newline at end of file diff --git a/Dockerfile.build b/Dockerfile.build index f09cbb2bb..1474e9c8c 100644 --- a/Dockerfile.build +++ b/Dockerfile.build @@ -1,30 +1,7 @@ -FROM microsoft/aspnetcore-build:2.0.0 - -# Install runtime dependencies -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates bzip2 libfontconfig \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - - # Install official PhantomJS release -RUN set -x \ - && apt-get update \ - && apt-get install -y --no-install-recommends \ - && mkdir /srv/var \ - && mkdir /tmp/phantomjs \ - # Download Phantom JS - && curl -L https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-2.1.1-linux-x86_64.tar.bz2 | tar -xj --strip-components=1 -C /tmp/phantomjs \ - # Copy binaries only - && mv /tmp/phantomjs/bin/phantomjs /usr/local/bin \ - # Create symbol link - # Clean up - && apt-get autoremove -y \ - && apt-get clean all \ - && rm -rf /tmp/* /var/lib/apt/lists/* - -RUN phantomjs --version +FROM squidex/dotnet:2.1-sdk-chromium-phantomjs-node as builder COPY src/Squidex/package.json /tmp/package.json + RUN cd /tmp \ && npm install \ && npm rebuild node-sass diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..306b9eced --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Qaisar Ahmad & Sebastian Stehle + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt deleted file mode 100644 index 95317685c..000000000 --- a/LICENSE.txt +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2017 Qaisar Ahmad & Sebastian Stehle - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file diff --git a/NuGet.Config b/NuGet.Config deleted file mode 100644 index c982f5720..000000000 --- a/NuGet.Config +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/Nuget.config b/Nuget.config new file mode 100644 index 000000000..8c24b91a1 --- /dev/null +++ b/Nuget.config @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index e8bd15355..eb5e7c5b6 100644 --- a/README.md +++ b/README.md @@ -4,21 +4,24 @@ Squidex is an open source headless CMS and content management hub. In contrast to a traditional CMS Squidex provides a rich API with OData filter and Swagger definitions. It is up to you to build your UI on top of it. It can be website, a native app or just another server. We build it with ASP.NET Core and CQRS and is tested for Windows and Linux on modern browsers. -[![Gitter](https://img.shields.io/gitter/room/nwjs/nw.js.svg?style=square)](https://gitter.im/squidex-cms/Lobby) [![Slack](https://img.shields.io/badge/chat-on_slack-E01765.svg?style=square)](https://squidex-slack.herokuapp.com/) [![Build Status](http://build.squidex.io/api/badges/Squidex/squidex/status.svg)](http://build.squidex.io/Squidex/squidex) [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2FSquidex%2Fsquidex.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2FSquidex%2Fsquidex?ref=badge_shield) +[![Discourse topics](https://img.shields.io/discourse/https/support.squidex.io/topics.svg)](https://support.squidex.io) [![Build Status](http://build.squidex.io/api/badges/Squidex/squidex/status.svg)](http://build.squidex.io/Squidex/squidex) [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2FSquidex%2Fsquidex.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2FSquidex%2Fsquidex?ref=badge_shield) Read the docs at [https://docs.squidex.io/](https://docs.squidex.io/) (work in progress) or just check out the code and play around. +## How to make feature requests or report bugs? + +Please join our community forum: https://support.squidex.io + ## Status -Current Version 1.1. Roadmap: https://trello.com/b/KakM4F3S/squidex-roadmap +Current Version 1.10.0. Roadmap: https://trello.com/b/KakM4F3S/squidex-roadmap ## Prerequisites * [Visual Studio Code](https://code.visualstudio.com/) or [Visual Studio 2017](https://www.visualstudio.com/vs/visual-studio-2017-rc/) -* [Node.js](https://nodejs.org/en/) -* [.NET Core SDK](https://www.microsoft.com/net/download/core#/current) (Already part of Visual Studio 2017) +* [Node.js](https://nodejs.org/en/) (development only) * [MongoDB](https://www.mongodb.com/) -* [Redis](https://redis.io/download) (If you want to run Squidex on multiple hosts) +* [.NET Core SDK](https://www.microsoft.com/net/download/core#/current) (Already part of Visual Studio 2017) ## Contributors @@ -29,9 +32,10 @@ Current Version 1.1. Roadmap: https://trello.com/b/KakM4F3S/squidex-roadmap ### Contributors -* [pushrbx](https://pushrbx.net/): Azure Store Support. +* [pushrbx](https://pushrbx.net/): Azure Store support. * [cpmstars](https://www.cpmstars.com): Asset support for rich editor. * [civicplus](https://www.civicplus.com/) ([Avd6977](https://github.com/Avd6977), [dsbegnoce](https://github.com/dsbegnoche)): Google Maps support, custom regex patterns and a lot of small improvements. +* [razims](https://github.com/razims): GridFS support. ## Contributing diff --git a/Squidex.ruleset b/Squidex.ruleset index 6c96f8b6b..20a67c5f9 100644 --- a/Squidex.ruleset +++ b/Squidex.ruleset @@ -81,6 +81,7 @@ + \ No newline at end of file diff --git a/Squidex.sln b/Squidex.sln index 67ffac379..6d8cfb71d 100644 --- a/Squidex.sln +++ b/Squidex.sln @@ -63,6 +63,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Migrate_01", "tools\Migrate EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Tests", "tests\Squidex.Tests\Squidex.Tests.csproj", "{7E8CC864-4C6E-496F-A672-9F9AD8874835}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "extensions", "extensions", "{FB8BC3A2-2010-4C3C-A87D-D4A98C05EE52}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Extensions", "extensions\Squidex.Extensions\Squidex.Extensions.csproj", "{F3C41B82-6A67-409A-B7FE-54543EE4F38B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -321,6 +325,18 @@ Global {7E8CC864-4C6E-496F-A672-9F9AD8874835}.Release|x64.Build.0 = Release|Any CPU {7E8CC864-4C6E-496F-A672-9F9AD8874835}.Release|x86.ActiveCfg = Release|Any CPU {7E8CC864-4C6E-496F-A672-9F9AD8874835}.Release|x86.Build.0 = Release|Any CPU + {F3C41B82-6A67-409A-B7FE-54543EE4F38B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F3C41B82-6A67-409A-B7FE-54543EE4F38B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F3C41B82-6A67-409A-B7FE-54543EE4F38B}.Debug|x64.ActiveCfg = Debug|Any CPU + {F3C41B82-6A67-409A-B7FE-54543EE4F38B}.Debug|x64.Build.0 = Debug|Any CPU + {F3C41B82-6A67-409A-B7FE-54543EE4F38B}.Debug|x86.ActiveCfg = Debug|Any CPU + {F3C41B82-6A67-409A-B7FE-54543EE4F38B}.Debug|x86.Build.0 = Debug|Any CPU + {F3C41B82-6A67-409A-B7FE-54543EE4F38B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F3C41B82-6A67-409A-B7FE-54543EE4F38B}.Release|Any CPU.Build.0 = Release|Any CPU + {F3C41B82-6A67-409A-B7FE-54543EE4F38B}.Release|x64.ActiveCfg = Release|Any CPU + {F3C41B82-6A67-409A-B7FE-54543EE4F38B}.Release|x64.Build.0 = Release|Any CPU + {F3C41B82-6A67-409A-B7FE-54543EE4F38B}.Release|x86.ActiveCfg = Release|Any CPU + {F3C41B82-6A67-409A-B7FE-54543EE4F38B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -335,7 +351,7 @@ Global {C1E5BBB6-6B6A-4DE5-B19D-0538304DE343} = {8CF53B92-5EB1-461D-98F8-70DA9B603FBF} {945871B1-77B8-43FB-B53C-27CF385AB756} = {8CF53B92-5EB1-461D-98F8-70DA9B603FBF} {B51126A8-0D75-4A79-867D-10724EC6AC84} = {94207AA6-4923-4183-A558-E0F8196B8CA3} - {5E75AB7D-6F01-4313-AFF1-7F7128FFD71F} = {4C6B06C2-6D77-4E0E-AE32-D7050236433A} + {5E75AB7D-6F01-4313-AFF1-7F7128FFD71F} = {8CF53B92-5EB1-461D-98F8-70DA9B603FBF} {C9809D59-6665-471E-AD87-5AC624C65892} = {4C6B06C2-6D77-4E0E-AE32-D7050236433A} {C0D540F0-9158-4528-BFD8-BEAE6EAE45EA} = {4C6B06C2-6D77-4E0E-AE32-D7050236433A} {F7771E22-47BD-45C4-A133-FD7F1DE27CA0} = {C0D540F0-9158-4528-BFD8-BEAE6EAE45EA} @@ -349,6 +365,7 @@ Global {AA003372-CD8D-4DBC-962C-F61E0C93CF05} = {C9809D59-6665-471E-AD87-5AC624C65892} {7DA5B308-D950-4496-93D5-21D6C4D91644} = {C9809D59-6665-471E-AD87-5AC624C65892} {A4823E14-C0E5-4A4D-B28F-27424C25C3C7} = {94207AA6-4923-4183-A558-E0F8196B8CA3} + {F3C41B82-6A67-409A-B7FE-54543EE4F38B} = {FB8BC3A2-2010-4C3C-A87D-D4A98C05EE52} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {02F2E872-3141-44F5-BD6A-33CD84E9FE08} diff --git a/Squidex.sln.DotSettings b/Squidex.sln.DotSettings deleted file mode 100644 index ecdd59c86..000000000 --- a/Squidex.sln.DotSettings +++ /dev/null @@ -1,50 +0,0 @@ - - False - True - False - True - False - True - - False - True - - False - True - - - False - True - - False - True - - - False - True - - - True - - - - - - - - - <?xml version="1.0" encoding="utf-16"?><Profile name="Header"><CSUpdateFileHeader>True</CSUpdateFileHeader></Profile> - <?xml version="1.0" encoding="utf-16"?><Profile name="Namespaces"><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings><EmbraceInRegion>False</EmbraceInRegion><RegionName></RegionName></CSOptimizeUsings><CSUpdateFileHeader>True</CSUpdateFileHeader></Profile> - <?xml version="1.0" encoding="utf-16"?><Profile name="Typescript"><JsInsertSemicolon>True</JsInsertSemicolon><FormatAttributeQuoteDescriptor>True</FormatAttributeQuoteDescriptor><CorrectVariableKindsDescriptor>True</CorrectVariableKindsDescriptor><VariablesToInnerScopesDescriptor>True</VariablesToInnerScopesDescriptor><StringToTemplatesDescriptor>True</StringToTemplatesDescriptor><RemoveRedundantQualifiersTs>True</RemoveRedundantQualifiersTs><OptimizeImportsTs>True</OptimizeImportsTs></Profile> - False - ========================================================================== - Squidex Headless CMS -========================================================================== - Copyright (c) Squidex UG (haftungsbeschraenkt) - All rights reserved. Licensed under the MIT license. -========================================================================== - - True - True - True - \ No newline at end of file diff --git a/extensions/Squidex.Extensions/Actions/Algolia/AlgoliaAction.cs b/extensions/Squidex.Extensions/Actions/Algolia/AlgoliaAction.cs new file mode 100644 index 000000000..baff5dc43 --- /dev/null +++ b/extensions/Squidex.Extensions/Actions/Algolia/AlgoliaAction.cs @@ -0,0 +1,34 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Core.Rules; + +namespace Squidex.Extensions.Actions.Algolia +{ + [RuleActionHandler(typeof(AlgoliaActionHandler))] + [RuleAction( + IconImage = "", + IconColor = "#0d9bf9", + Display = "Populate Algolia index", + Description = "Populate and synchronize indices in Algolia for full text search.", + ReadMore = "https://www.algolia.com/")] + public sealed class AlgoliaAction : RuleAction + { + [Required] + [Display(Name = "Application Id", Description = "The application ID.")] + public string AppId { get; set; } + + [Required] + [Display(Name = "Api Key", Description = "The API key to grant access to Squidex.")] + public string ApiKey { get; set; } + + [Required] + [Display(Name = "Index Name", Description = "THe name of the index.")] + public string IndexName { get; set; } + } +} diff --git a/extensions/Squidex.Extensions/Actions/Algolia/AlgoliaActionHandler.cs b/extensions/Squidex.Extensions/Actions/Algolia/AlgoliaActionHandler.cs new file mode 100644 index 000000000..80d7e23d1 --- /dev/null +++ b/extensions/Squidex.Extensions/Actions/Algolia/AlgoliaActionHandler.cs @@ -0,0 +1,112 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Algolia.Search; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents; + +namespace Squidex.Extensions.Actions.Algolia +{ + public sealed class AlgoliaActionHandler : RuleActionHandler + { + private const string DescriptionIgnore = "Ignore"; + + private readonly ClientPool<(string AppId, string ApiKey, string IndexName), Index> clients; + + public AlgoliaActionHandler(RuleEventFormatter formatter) + : base(formatter) + { + clients = new ClientPool<(string AppId, string ApiKey, string IndexName), Index>(key => + { + var client = new AlgoliaClient(key.AppId, key.ApiKey); + + return client.InitIndex(key.IndexName); + }); + } + + protected override (string Description, AlgoliaJob Data) CreateJob(EnrichedEvent @event, AlgoliaAction action) + { + if (@event is EnrichedContentEvent contentEvent) + { + var contentId = contentEvent.Id.ToString(); + + var ruleDescription = string.Empty; + var ruleJob = new AlgoliaJob + { + AppId = action.AppId, + ApiKey = action.ApiKey, + ContentId = contentId, + IndexName = Format(action.IndexName, @event) + }; + + if (contentEvent.Type == EnrichedContentEventType.Deleted || + contentEvent.Type == EnrichedContentEventType.Unpublished) + { + ruleDescription = $"Delete entry from Algolia index: {action.IndexName}"; + } + else + { + ruleDescription = $"Add entry to Algolia index: {action.IndexName}"; + + ruleJob.Content = ToPayload(contentEvent); + ruleJob.Content["objectID"] = contentId; + } + + return (ruleDescription, ruleJob); + } + + return (DescriptionIgnore, new AlgoliaJob()); + } + + protected override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(AlgoliaJob job) + { + if (string.IsNullOrWhiteSpace(job.AppId)) + { + return (DescriptionIgnore, null); + } + + var index = clients.GetClient((job.AppId, job.ApiKey, job.IndexName)); + + try + { + if (job.Content != null) + { + var response = await index.PartialUpdateObjectAsync(job.Content); + + return (response.ToString(Formatting.Indented), null); + } + else + { + var response = await index.DeleteObjectAsync(job.ContentId); + + return (response.ToString(Formatting.Indented), null); + } + } + catch (AlgoliaException ex) + { + return (ex.Message, ex); + } + } + } + + public sealed class AlgoliaJob + { + public string AppId { get; set; } + + public string ApiKey { get; set; } + + public string ContentId { get; set; } + + public string IndexName { get; set; } + + public JObject Content { get; set; } + } +} diff --git a/extensions/Squidex.Extensions/Actions/AzureQueue/AzureQueueAction.cs b/extensions/Squidex.Extensions/Actions/AzureQueue/AzureQueueAction.cs new file mode 100644 index 000000000..0ee5de663 --- /dev/null +++ b/extensions/Squidex.Extensions/Actions/AzureQueue/AzureQueueAction.cs @@ -0,0 +1,41 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Text.RegularExpressions; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Infrastructure; + +namespace Squidex.Extensions.Actions.AzureQueue +{ + [RuleActionHandler(typeof(AzureQueueActionHandler))] + [RuleAction( + IconImage = "", + IconColor = "#0d9bf9", + Display = "Send to Azure Queue", + Description = "Send an event to azure queue storage.", + ReadMore = "https://azure.microsoft.com/en-us/services/storage/queues/")] + public sealed class AzureQueueAction : RuleAction + { + [Required] + [Display(Name = "Connection String", Description = "The connection string to the storage account.")] + public string ConnectionString { get; set; } + + [Required] + [Display(Name = "Queue", Description = "The name of the queue.")] + public string Queue { get; set; } + + protected override IEnumerable CustomValidate() + { + if (!string.IsNullOrWhiteSpace(Queue) && !Regex.IsMatch(Queue, "^[a-z][a-z0-9]{2,}(\\-[a-z0-9]+)*$")) + { + yield return new ValidationError("Queue must be valid azure queue name.", nameof(Queue)); + } + } + } +} diff --git a/extensions/Squidex.Extensions/Actions/AzureQueue/AzureQueueActionHandler.cs b/extensions/Squidex.Extensions/Actions/AzureQueue/AzureQueueActionHandler.cs new file mode 100644 index 000000000..ed0c9d8c9 --- /dev/null +++ b/extensions/Squidex.Extensions/Actions/AzureQueue/AzureQueueActionHandler.cs @@ -0,0 +1,68 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Microsoft.WindowsAzure.Storage; +using Microsoft.WindowsAzure.Storage.Queue; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents; + +namespace Squidex.Extensions.Actions.AzureQueue +{ + public sealed class AzureQueueActionHandler : RuleActionHandler + { + private readonly ClientPool<(string ConnectionString, string QueueName), CloudQueue> clients; + + public AzureQueueActionHandler(RuleEventFormatter formatter) + : base(formatter) + { + clients = new ClientPool<(string ConnectionString, string QueueName), CloudQueue>(key => + { + var storageAccount = CloudStorageAccount.Parse(key.ConnectionString); + + var queueClient = storageAccount.CreateCloudQueueClient(); + var queueRef = queueClient.GetQueueReference(key.QueueName); + + return queueRef; + }); + } + + protected override (string Description, AzureQueueJob Data) CreateJob(EnrichedEvent @event, AzureQueueAction action) + { + var queueName = Format(action.Queue, @event); + + var ruleDescription = $"Send AzureQueueJob to azure queue '{queueName}'"; + var ruleJob = new AzureQueueJob + { + QueueConnectionString = action.ConnectionString, + QueueName = queueName, + MessageBodyV2 = ToEnvelopeJson(@event) + }; + + return (ruleDescription, ruleJob); + } + + protected override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(AzureQueueJob job) + { + var queue = clients.GetClient((job.QueueConnectionString, job.QueueName)); + + await queue.AddMessageAsync(new CloudQueueMessage(job.MessageBodyV2)); + + return ("Completed", null); + } + } + + public sealed class AzureQueueJob + { + public string QueueConnectionString { get; set; } + + public string QueueName { get; set; } + + public string MessageBodyV2 { get; set; } + } +} diff --git a/extensions/Squidex.Extensions/Actions/ClientPool.cs b/extensions/Squidex.Extensions/Actions/ClientPool.cs new file mode 100644 index 000000000..74208afd3 --- /dev/null +++ b/extensions/Squidex.Extensions/Actions/ClientPool.cs @@ -0,0 +1,50 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +#pragma warning disable RECS0108 // Warns about static fields in generic types + +namespace Squidex.Extensions.Actions +{ + internal sealed class ClientPool + { + private static readonly TimeSpan TTL = TimeSpan.FromMinutes(30); + private readonly MemoryCache memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions())); + private readonly Func> factory; + + public ClientPool(Func factory) + { + this.factory = x => Task.FromResult(factory(x)); + } + + public ClientPool(Func> factory) + { + this.factory = factory; + } + + public TClient GetClient(TKey key) + { + return GetClientAsync(key).Result; + } + + public async Task GetClientAsync(TKey key) + { + if (!memoryCache.TryGetValue(key, out var client)) + { + client = await factory(key); + + memoryCache.Set(key, client, TTL); + } + + return client; + } + } +} diff --git a/extensions/Squidex.Extensions/Actions/Discourse/DiscourseAction.cs b/extensions/Squidex.Extensions/Actions/Discourse/DiscourseAction.cs new file mode 100644 index 000000000..4711dec26 --- /dev/null +++ b/extensions/Squidex.Extensions/Actions/Discourse/DiscourseAction.cs @@ -0,0 +1,50 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Infrastructure; + +namespace Squidex.Extensions.Actions.Discourse +{ + [RuleActionHandler(typeof(DiscourseActionHandler))] + [RuleAction( + IconImage = "", + IconColor = "#eB6121", + Display = "Post to discourse", + Description = "Create a post or topic at discourse.", + ReadMore = "https://www.discourse.org/")] + public sealed class DiscourseAction : RuleAction + { + [AbsoluteUrl] + [Required] + [Display(Name = "Url", Description = "he url to the discourse server.")] + public Uri Url { get; set; } + + [Required] + [Display(Name = "Api Key", Description = "The api key to authenticate to your discourse server.")] + public string ApiKey { get; set; } + + [Required] + [Display(Name = "Api Username", Description = "The api username to authenticate to your discourse server.")] + public string ApiUsername { get; set; } + + [Required] + [Display(Name = "Text", Description = "The text as markdown.")] + public string Text { get; set; } + + [Display(Name = "Title", Description = "The optional title when creating new topics.")] + public string Title { get; set; } + + [Display(Name = "Topic", Description = "The optional topic id.")] + public int? Topic { get; set; } + + [Display(Name = "Category", Description = "The optional category id.")] + public int? Category { get; set; } + } +} diff --git a/extensions/Squidex.Extensions/Actions/Discourse/DiscourseActionHandler.cs b/extensions/Squidex.Extensions/Actions/Discourse/DiscourseActionHandler.cs new file mode 100644 index 000000000..22e9131a1 --- /dev/null +++ b/extensions/Squidex.Extensions/Actions/Discourse/DiscourseActionHandler.cs @@ -0,0 +1,84 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents; + +namespace Squidex.Extensions.Actions.Discourse +{ + public sealed class DiscourseActionHandler : RuleActionHandler + { + private const string DescriptionCreatePost = "Create discourse Post"; + private const string DescriptionCreateTopic = "Create discourse Topic"; + + private readonly IHttpClientFactory httpClientFactory; + + public DiscourseActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory) + : base(formatter) + { + this.httpClientFactory = httpClientFactory; + } + + protected override (string Description, DiscourseJob Data) CreateJob(EnrichedEvent @event, DiscourseAction action) + { + var url = $"{action.Url.ToString().TrimEnd('/')}/posts.json?api_key={action.ApiKey}&api_username={action.ApiUsername}"; + + var json = + new JObject( + new JProperty("raw", Format(action.Text, @event)), + new JProperty("title", Format(action.Title, @event))); + + if (action.Topic.HasValue) + { + json.Add(new JProperty("topic_id", action.Topic.Value)); + } + + if (action.Category.HasValue) + { + json.Add(new JProperty("category", action.Category.Value)); + } + + var ruleJob = new DiscourseJob + { + RequestUrl = url, + RequestBody = json.ToString() + }; + + var description = + action.Topic.HasValue ? + DescriptionCreateTopic : + DescriptionCreatePost; + + return (description, ruleJob); + } + + protected override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(DiscourseJob job) + { + using (var httpClient = httpClientFactory.CreateClient()) + { + var request = new HttpRequestMessage(HttpMethod.Post, job.RequestUrl) + { + Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json") + }; + + return await httpClient.OneWayRequestAsync(request, job.RequestBody); + } + } + } + + public sealed class DiscourseJob + { + public string RequestUrl { get; set; } + + public string RequestBody { get; set; } + } +} diff --git a/extensions/Squidex.Extensions/Actions/ElasticSearch/ElasticSearchAction.cs b/extensions/Squidex.Extensions/Actions/ElasticSearch/ElasticSearchAction.cs new file mode 100644 index 000000000..620f9ef6e --- /dev/null +++ b/extensions/Squidex.Extensions/Actions/ElasticSearch/ElasticSearchAction.cs @@ -0,0 +1,43 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Infrastructure; + +namespace Squidex.Extensions.Actions.ElasticSearch +{ + [RuleActionHandler(typeof(ElasticSearchActionHandler))] + [RuleAction( + IconImage = "", + IconColor = "#1e5470", + Display = "Populate ElasticSearch index", + Description = "Populate and synchronize indices in ElasticSearch for full text search.", + ReadMore = "https://www.elastic.co/")] + public sealed class ElasticSearchAction : RuleAction + { + [AbsoluteUrl] + [Required] + [Display(Name = "Host", Description = "The hostname of the elastic search instance or cluster.")] + public Uri Host { get; set; } + + [Required] + [Display(Name = "Index Name", Description = "The name of the index.")] + public string IndexName { get; set; } + + [Required] + [Display(Name = "Index Type", Description = "The name of the index type.")] + public string IndexType { get; set; } + + [Display(Name = "Username", Description = "The optional username.")] + public string Username { get; set; } + + [Display(Name = "Password", Description = "The optional password.")] + public string Password { get; set; } + } +} diff --git a/extensions/Squidex.Extensions/Actions/ElasticSearch/ElasticSearchActionHandler.cs b/extensions/Squidex.Extensions/Actions/ElasticSearch/ElasticSearchActionHandler.cs new file mode 100644 index 000000000..78655e4fd --- /dev/null +++ b/extensions/Squidex.Extensions/Actions/ElasticSearch/ElasticSearchActionHandler.cs @@ -0,0 +1,125 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Elasticsearch.Net; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents; + +namespace Squidex.Extensions.Actions.ElasticSearch +{ + public sealed class ElasticSearchActionHandler : RuleActionHandler + { + private const string DescriptionIgnore = "Ignore"; + + private readonly ClientPool<(Uri Host, string Username, string Password), ElasticLowLevelClient> clients; + + public ElasticSearchActionHandler(RuleEventFormatter formatter) + : base(formatter) + { + clients = new ClientPool<(Uri Host, string Username, string Password), ElasticLowLevelClient>(key => + { + var config = new ConnectionConfiguration(key.Host); + + if (!string.IsNullOrEmpty(key.Username) && !string.IsNullOrWhiteSpace(key.Password)) + { + config = config.BasicAuthentication(key.Username, key.Password); + } + + return new ElasticLowLevelClient(config); + }); + } + + protected override (string Description, ElasticSearchJob Data) CreateJob(EnrichedEvent @event, ElasticSearchAction action) + { + if (@event is EnrichedContentEvent contentEvent) + { + var contentId = contentEvent.Id.ToString(); + + var ruleDescription = string.Empty; + var ruleJob = new ElasticSearchJob + { + Host = action.Host.ToString(), + ContentId = contentId, + IndexName = Format(action.IndexName, @event), + IndexType = Format(action.IndexType, @event) + }; + + if (contentEvent.Type == EnrichedContentEventType.Deleted || + contentEvent.Type == EnrichedContentEventType.Unpublished) + { + ruleDescription = $"Delete entry index: {action.IndexName}"; + } + else + { + ruleDescription = $"Upsert to index: {action.IndexName}"; + + ruleJob.Content = ToPayload(contentEvent); + ruleJob.Content["objectID"] = contentId; + } + + ruleJob.Username = action.Username; + ruleJob.Password = action.Password; + + return (ruleDescription, ruleJob); + } + + return (DescriptionIgnore, new ElasticSearchJob()); + } + + protected override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(ElasticSearchJob job) + { + if (string.IsNullOrWhiteSpace(job.Host)) + { + return (DescriptionIgnore, null); + } + + var client = clients.GetClient((new Uri(job.Host, UriKind.Absolute), job.Username, job.Password)); + + try + { + if (job.Content != null) + { + var doc = job.Content.ToString(); + + var response = await client.IndexAsync(job.IndexName, job.IndexType, job.ContentId, doc); + + return (response.Body, response.OriginalException); + } + else + { + var response = await client.DeleteAsync(job.IndexName, job.IndexType, job.ContentId); + + return (response.Body, response.OriginalException); + } + } + catch (ElasticsearchClientException ex) + { + return (ex.Message, ex); + } + } + } + + public sealed class ElasticSearchJob + { + public string Host { get; set; } + + public string Username { get; set; } + + public string Password { get; set; } + + public string ContentId { get; set; } + + public string IndexName { get; set; } + + public string IndexType { get; set; } + + public JObject Content { get; set; } + } +} diff --git a/extensions/Squidex.Extensions/Actions/Fastly/FastlyAction.cs b/extensions/Squidex.Extensions/Actions/Fastly/FastlyAction.cs new file mode 100644 index 000000000..928dab189 --- /dev/null +++ b/extensions/Squidex.Extensions/Actions/Fastly/FastlyAction.cs @@ -0,0 +1,30 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Core.Rules; + +namespace Squidex.Extensions.Actions.Fastly +{ + [RuleActionHandler(typeof(FastlyActionHandler))] + [RuleAction( + IconImage = "", + IconColor = "#e23335", + Display = "Purge fastly cache", + Description = "Remove entries from the fastly CDN cache.", + ReadMore = "https://www.fastly.com/")] + public sealed class FastlyAction : RuleAction + { + [Required] + [Display(Name = "Api Key", Description = "The API key to grant access to Squidex.")] + public string ApiKey { get; set; } + + [Required] + [Display(Name = "Service Id", Description = "The ID of the fastly service.")] + public string ServiceId { get; set; } + } +} diff --git a/extensions/Squidex.Extensions/Actions/Fastly/FastlyActionHandler.cs b/extensions/Squidex.Extensions/Actions/Fastly/FastlyActionHandler.cs new file mode 100644 index 000000000..a663a7f74 --- /dev/null +++ b/extensions/Squidex.Extensions/Actions/Fastly/FastlyActionHandler.cs @@ -0,0 +1,67 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Net.Http; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents; +using Squidex.Infrastructure; + +namespace Squidex.Extensions.Actions.Fastly +{ + public sealed class FastlyActionHandler : RuleActionHandler + { + private const string Description = "Purge key in fastly"; + + private readonly IHttpClientFactory httpClientFactory; + + public FastlyActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory) + : base(formatter) + { + Guard.NotNull(httpClientFactory, nameof(httpClientFactory)); + + this.httpClientFactory = httpClientFactory; + } + + protected override (string Description, FastlyJob Data) CreateJob(EnrichedEvent @event, FastlyAction action) + { + var ruleJob = new FastlyJob + { + Key = @event.AggregateId.ToString(), + FastlyApiKey = action.ApiKey, + FastlyServiceID = action.ServiceId + }; + + return (Description, ruleJob); + } + + protected override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(FastlyJob job) + { + using (var httpClient = httpClientFactory.CreateClient()) + { + httpClient.Timeout = TimeSpan.FromSeconds(2); + + var requestUrl = $"https://api.fastly.com/service/{job.FastlyServiceID}/purge/{job.Key}"; + var request = new HttpRequestMessage(HttpMethod.Post, requestUrl); + + request.Headers.Add("Fastly-Key", job.FastlyApiKey); + + return await httpClient.OneWayRequestAsync(request); + } + } + } + + public sealed class FastlyJob + { + public string FastlyApiKey { get; set; } + + public string FastlyServiceID { get; set; } + + public string Key { get; set; } + } +} diff --git a/extensions/Squidex.Extensions/Actions/HttpHelper.cs b/extensions/Squidex.Extensions/Actions/HttpHelper.cs new file mode 100644 index 000000000..613c99cbc --- /dev/null +++ b/extensions/Squidex.Extensions/Actions/HttpHelper.cs @@ -0,0 +1,45 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Net.Http; +using System.Threading.Tasks; +using Squidex.Infrastructure.Http; + +namespace Squidex.Extensions.Actions +{ + public static class HttpHelper + { + public static async Task<(string Dump, Exception Exception)> OneWayRequestAsync(this HttpClient client, HttpRequestMessage request, string requestBody = null) + { + HttpResponseMessage response = null; + try + { + response = await client.SendAsync(request); + + var responseString = await response.Content.ReadAsStringAsync(); + + var requestDump = DumpFormatter.BuildDump(request, response, requestBody, responseString); + + Exception ex = null; + + if (!response.IsSuccessStatusCode) + { + ex = new HttpRequestException($"Response code does not indicate success: {(int)response.StatusCode} ({response.StatusCode})."); + } + + return (requestDump, ex); + } + catch (Exception ex) + { + var requestDump = DumpFormatter.BuildDump(request, response, requestBody, ex.ToString()); + + return (requestDump, ex); + } + } + } +} diff --git a/extensions/Squidex.Extensions/Actions/Medium/MediumAction.cs b/extensions/Squidex.Extensions/Actions/Medium/MediumAction.cs new file mode 100644 index 000000000..6ee4048bb --- /dev/null +++ b/extensions/Squidex.Extensions/Actions/Medium/MediumAction.cs @@ -0,0 +1,43 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Core.Rules; + +namespace Squidex.Extensions.Actions.Medium +{ + [RuleActionHandler(typeof(MediumActionHandler))] + [RuleAction( + IconImage = "", + IconColor = "#00ab6c", + Display = "Post to Medium", + Description = "Create a new story or post at medium.", + ReadMore = "https://medium.com/")] + public sealed class MediumAction : RuleAction + { + [Required] + [Display(Name = "Access Token", Description = "The self issued access token.")] + public string AccessToken { get; set; } + + [Required] + [Display(Name = "Title", Description = "The title, used for the url.")] + public string Title { get; set; } + + [Required] + [Display(Name = "Content", Description = "The content, either html or markdown.")] + public string Content { get; set; } + + [Display(Name = "Canonical Url", Description = "The original home of this content, if it was originally published elsewhere.")] + public string CanonicalUrl { get; set; } + + [Display(Name = "Tags", Description = "The optional comma separated list of tags.")] + public string Tags { get; set; } + + [Display(Name = "Is Html", Description = "Indicates whether the content is markdown or html.")] + public bool IsHtml { get; set; } + } +} diff --git a/extensions/Squidex.Extensions/Actions/Medium/MediumActionHandler.cs b/extensions/Squidex.Extensions/Actions/Medium/MediumActionHandler.cs new file mode 100644 index 000000000..9e594c7ec --- /dev/null +++ b/extensions/Squidex.Extensions/Actions/Medium/MediumActionHandler.cs @@ -0,0 +1,131 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents; +using Squidex.Infrastructure.Http; + +namespace Squidex.Extensions.Actions.Medium +{ + public sealed class MediumActionHandler : RuleActionHandler + { + private const string Description = "Post to medium"; + + private readonly IHttpClientFactory httpClientFactory; + + public MediumActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory) + : base(formatter) + { + this.httpClientFactory = httpClientFactory; + } + + protected override (string Description, MediumJob Data) CreateJob(EnrichedEvent @event, MediumAction action) + { + var requestBody = + new JObject( + new JProperty("title", Format(action.Title, @event)), + new JProperty("contentFormat", action.IsHtml ? "html" : "markdown"), + new JProperty("content", Format(action.Content, @event)), + new JProperty("canonicalUrl", Format(action.CanonicalUrl, @event)), + new JProperty("tags", ParseTags(@event, action))); + + var ruleJob = new MediumJob { AccessToken = action.AccessToken, RequestBody = requestBody.ToString(Formatting.Indented) }; + + return (Description, ruleJob); + } + + private JArray ParseTags(EnrichedEvent @event, MediumAction action) + { + if (string.IsNullOrWhiteSpace(action.Tags)) + { + return null; + } + + string[] tags; + try + { + var jsonTags = Format(action.Tags, @event); + + tags = JsonConvert.DeserializeObject(jsonTags); + } + catch + { + tags = action.Tags.Split(','); + } + + return new JArray(tags); + } + + protected override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(MediumJob job) + { + using (var httpClient = httpClientFactory.CreateClient()) + { + httpClient.Timeout = TimeSpan.FromSeconds(4); + httpClient.DefaultRequestHeaders.Add("Accept", "application/json"); + httpClient.DefaultRequestHeaders.Add("Accept-Charset", "utf-8"); + httpClient.DefaultRequestHeaders.Add("User-Agent", "Squidex Headless CMS"); + + string id; + + HttpResponseMessage response = null; + + var meRequest = BuildMeRequest(job); + try + { + response = await httpClient.SendAsync(meRequest); + + var responseString = await response.Content.ReadAsStringAsync(); + var responseJson = JToken.Parse(responseString); + + id = responseJson["data"]["id"].ToString(); + } + catch (Exception ex) + { + var requestDump = DumpFormatter.BuildDump(meRequest, response, ex.ToString()); + + return (requestDump, ex); + } + + return await httpClient.OneWayRequestAsync(BuildPostRequest(job, id), job.RequestBody); + } + } + + private static HttpRequestMessage BuildPostRequest(MediumJob job, string id) + { + var request = new HttpRequestMessage(HttpMethod.Post, $"https://api.medium.com/v1/users/{id}/posts") + { + Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json") + }; + + request.Headers.Add("Authorization", $"Bearer {job.AccessToken}"); + + return request; + } + + private static HttpRequestMessage BuildMeRequest(MediumJob job) + { + var request = new HttpRequestMessage(HttpMethod.Get, "https://api.medium.com/v1/me"); + + request.Headers.Add("Authorization", $"Bearer {job.AccessToken}"); + + return request; + } + } + + public sealed class MediumJob + { + public string RequestBody { get; set; } + + public string AccessToken { get; set; } + } +} diff --git a/extensions/Squidex.Extensions/Actions/Prerender/PrerenderAction.cs b/extensions/Squidex.Extensions/Actions/Prerender/PrerenderAction.cs new file mode 100644 index 000000000..47c37eafa --- /dev/null +++ b/extensions/Squidex.Extensions/Actions/Prerender/PrerenderAction.cs @@ -0,0 +1,30 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Core.Rules; + +namespace Squidex.Extensions.Actions.Prerender +{ + [RuleActionHandler(typeof(PrerenderActionHandler))] + [RuleAction( + IconImage = "", + IconColor = "#2c3e50", + Display = "Recache URL", + Description = "Recache a certain URL or cache a URL for the first time.", + ReadMore = "https://prerender.io")] + public sealed class PrerenderAction : RuleAction + { + [Required] + [Display(Name = "Token", Description = "The prerender token from your account.")] + public string Token { get; set; } + + [Required] + [Display(Name = "Url", Description = "The url to recache.")] + public string Url { get; set; } + } +} diff --git a/extensions/Squidex.Extensions/Actions/Prerender/PrerenderActionHandler.cs b/extensions/Squidex.Extensions/Actions/Prerender/PrerenderActionHandler.cs new file mode 100644 index 000000000..e8c722490 --- /dev/null +++ b/extensions/Squidex.Extensions/Actions/Prerender/PrerenderActionHandler.cs @@ -0,0 +1,58 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents; + +namespace Squidex.Extensions.Actions.Prerender +{ + public sealed class PrerenderActionHandler : RuleActionHandler + { + private readonly IHttpClientFactory httpClientFactory; + + public PrerenderActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory) + : base(formatter) + { + this.httpClientFactory = httpClientFactory; + } + + protected override (string Description, PrerenderJob Data) CreateJob(EnrichedEvent @event, PrerenderAction action) + { + var url = Format(action.Url, @event); + + var request = + new JObject( + new JProperty("prerenderToken", action.Token), + new JProperty("url", url)); + + return ($"Recache {url}", new PrerenderJob { RequestBody = request.ToString() }); + } + + protected override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(PrerenderJob job) + { + using (var httpClient = httpClientFactory.CreateClient()) + { + var request = new HttpRequestMessage(HttpMethod.Post, "https://api.prerender.io/recache") + { + Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json") + }; + + return await httpClient.OneWayRequestAsync(request, job.RequestBody); + } + } + } + + public sealed class PrerenderJob + { + public string RequestBody { get; set; } + } +} diff --git a/extensions/Squidex.Extensions/Actions/RuleActionAttribute.cs b/extensions/Squidex.Extensions/Actions/RuleActionAttribute.cs new file mode 100644 index 000000000..c5fa0343f --- /dev/null +++ b/extensions/Squidex.Extensions/Actions/RuleActionAttribute.cs @@ -0,0 +1,25 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; + +namespace Squidex.Extensions.Actions +{ + [AttributeUsage(AttributeTargets.Class, Inherited = false)] + public sealed class RuleActionAttribute : Attribute + { + public string ReadMore { get; set; } + + public string IconImage { get; set; } + + public string IconColor { get; set; } + + public string Display { get; set; } + + public string Description { get; set; } + } +} diff --git a/extensions/Squidex.Extensions/Actions/RuleActionHandlerAttribute.cs b/extensions/Squidex.Extensions/Actions/RuleActionHandlerAttribute.cs new file mode 100644 index 000000000..5da96ebf5 --- /dev/null +++ b/extensions/Squidex.Extensions/Actions/RuleActionHandlerAttribute.cs @@ -0,0 +1,31 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Infrastructure; + +namespace Squidex.Extensions.Actions +{ + [AttributeUsage(AttributeTargets.Class, Inherited = false)] + public sealed class RuleActionHandlerAttribute : Attribute + { + public Type HandlerType { get; } + + public RuleActionHandlerAttribute(Type handlerType) + { + Guard.NotNull(handlerType, nameof(handlerType)); + + HandlerType = handlerType; + + if (!typeof(IRuleActionHandler).IsAssignableFrom(handlerType)) + { + throw new ArgumentException($"Handler type must implement {typeof(IRuleActionHandler)}.", nameof(handlerType)); + } + } + } +} diff --git a/extensions/Squidex.Extensions/Actions/RuleElement.cs b/extensions/Squidex.Extensions/Actions/RuleElement.cs new file mode 100644 index 000000000..6d7244c01 --- /dev/null +++ b/extensions/Squidex.Extensions/Actions/RuleElement.cs @@ -0,0 +1,26 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; + +namespace Squidex.Extensions.Actions +{ + public sealed class RuleElement + { + public Type Type { get; set; } + + public string ReadMore { get; set; } + + public string IconImage { get; set; } + + public string IconColor { get; set; } + + public string Display { get; set; } + + public string Description { get; set; } + } +} diff --git a/extensions/Squidex.Extensions/Actions/RuleElementRegistry.cs b/extensions/Squidex.Extensions/Actions/RuleElementRegistry.cs new file mode 100644 index 000000000..b8e3dae0a --- /dev/null +++ b/extensions/Squidex.Extensions/Actions/RuleElementRegistry.cs @@ -0,0 +1,100 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Infrastructure; + +namespace Squidex.Extensions.Actions +{ + public static class RuleElementRegistry + { + private const string Suffix = "Action"; + private static readonly HashSet ActionHandlerTypes = new HashSet(); + private static readonly Dictionary ActionTypes = new Dictionary(); + private static readonly Dictionary TriggerTypes = new Dictionary + { + ["ContentChanged"] = new RuleElement + { + IconImage = "", + IconColor = "#3389ff", + Display = "Content changed", + Description = "For content changes like created, updated, published, unpublished..." + }, + + ["AssetChanged"] = new RuleElement + { + IconImage = "", + IconColor = "#3389ff", + Display = "Asset changed", + Description = "For asset changes like uploaded, updated, renamed, deleted..." + } + }; + + public static IReadOnlyDictionary Triggers + { + get { return TriggerTypes; } + } + + public static IReadOnlyDictionary Actions + { + get { return ActionTypes; } + } + + public static IReadOnlyCollection ActionHandlers + { + get { return ActionHandlerTypes; } + } + + static RuleElementRegistry() + { + var actionTypes = + typeof(RuleElementRegistry).Assembly + .GetTypes() + .Where(x => typeof(RuleAction).IsAssignableFrom(x)) + .Where(x => x.GetCustomAttribute() != null) + .Where(x => x.GetCustomAttribute() != null) + .ToList(); + + foreach (var actionType in actionTypes) + { + var name = actionType.Name; + + if (name.EndsWith(Suffix, StringComparison.Ordinal)) + { + name = name.Substring(0, name.Length - Suffix.Length); + } + + var metadata = actionType.GetCustomAttribute(); + + ActionTypes[name] = + new RuleElement + { + Type = actionType, + Display = metadata.Display, + Description = metadata.Description, + IconColor = metadata.IconColor, + IconImage = metadata.IconImage, + ReadMore = metadata.ReadMore + }; + + ActionHandlerTypes.Add(actionType.GetCustomAttribute().HandlerType); + } + } + + public static void RegisterTypes(TypeNameRegistry typeNameRegistry) + { + foreach (var actionType in ActionTypes.Values) + { + typeNameRegistry.Map(actionType.Type, actionType.Type.Name); + } + } + } +} diff --git a/extensions/Squidex.Extensions/Actions/Slack/SlackAction.cs b/extensions/Squidex.Extensions/Actions/Slack/SlackAction.cs new file mode 100644 index 000000000..792a0f3fc --- /dev/null +++ b/extensions/Squidex.Extensions/Actions/Slack/SlackAction.cs @@ -0,0 +1,33 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Infrastructure; + +namespace Squidex.Extensions.Actions.Slack +{ + [RuleActionHandler(typeof(SlackActionHandler))] + [RuleAction( + IconImage = "", + IconColor = "#5c3a58", + Display = "Send to Slack", + Description = "Create a status update at slack to a channel you define.", + ReadMore = "https://slack.com")] + public sealed class SlackAction : RuleAction + { + [AbsoluteUrl] + [Required] + [Display(Name = "Webhook Url", Description = "The slack webhook url.")] + public Uri WebhookUrl { get; set; } + + [Required] + [Display(Name = "Text", Description = "The text that is sent as message to slack.")] + public string Text { get; set; } + } +} diff --git a/extensions/Squidex.Extensions/Actions/Slack/SlackActionHandler.cs b/extensions/Squidex.Extensions/Actions/Slack/SlackActionHandler.cs new file mode 100644 index 000000000..aeb40f32c --- /dev/null +++ b/extensions/Squidex.Extensions/Actions/Slack/SlackActionHandler.cs @@ -0,0 +1,71 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents; +using Squidex.Infrastructure; + +namespace Squidex.Extensions.Actions.Slack +{ + public sealed class SlackActionHandler : RuleActionHandler + { + private const string Description = "Send message to slack"; + + private readonly IHttpClientFactory httpClientFactory; + + public SlackActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory) + : base(formatter) + { + Guard.NotNull(httpClientFactory, nameof(httpClientFactory)); + + this.httpClientFactory = httpClientFactory; + } + + protected override (string Description, SlackJob Data) CreateJob(EnrichedEvent @event, SlackAction action) + { + var body = + new JObject( + new JProperty("text", Format(action.Text, @event))); + + var ruleJob = new SlackJob + { + RequestUrl = action.WebhookUrl.ToString(), + RequestBody = body.ToString(Formatting.Indented) + }; + + return (Description, ruleJob); + } + + protected override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(SlackJob job) + { + using (var httpClient = httpClientFactory.CreateClient()) + { + httpClient.Timeout = TimeSpan.FromSeconds(2); + + var request = new HttpRequestMessage(HttpMethod.Post, job.RequestUrl) + { + Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json") + }; + + return await httpClient.OneWayRequestAsync(request, job.RequestBody); + } + } + } + + public sealed class SlackJob + { + public string RequestUrl { get; set; } + + public string RequestBody { get; set; } + } +} diff --git a/extensions/Squidex.Extensions/Actions/Twitter/TweetAction.cs b/extensions/Squidex.Extensions/Actions/Twitter/TweetAction.cs new file mode 100644 index 000000000..a16f98c6f --- /dev/null +++ b/extensions/Squidex.Extensions/Actions/Twitter/TweetAction.cs @@ -0,0 +1,34 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Core.Rules; + +namespace Squidex.Extensions.Actions.Twitter +{ + [RuleActionHandler(typeof(TweetActionHandler))] + [RuleAction( + IconImage = "", + IconColor = "#1da1f2", + Display = "Tweet", + Description = "Create a status update at Tweet to a your user account.", + ReadMore = "https://twitter.com")] + public sealed class TweetAction : RuleAction + { + [Required] + [Display(Name = "Access Token", Description = " The generated access token.")] + public string AccessToken { get; set; } + + [Required] + [Display(Name = "Access Secret", Description = " The generated access secret.")] + public string AccessSecret { get; set; } + + [Required] + [Display(Name = "Text", Description = "The text that is sent as tweet to twitter.")] + public string Text { get; set; } + } +} diff --git a/extensions/Squidex.Extensions/Actions/Twitter/TweetActionHandler.cs b/extensions/Squidex.Extensions/Actions/Twitter/TweetActionHandler.cs new file mode 100644 index 000000000..228d7196d --- /dev/null +++ b/extensions/Squidex.Extensions/Actions/Twitter/TweetActionHandler.cs @@ -0,0 +1,66 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using CoreTweet; +using Microsoft.Extensions.Options; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents; +using Squidex.Infrastructure; + +namespace Squidex.Extensions.Actions.Twitter +{ + public sealed class TweetActionHandler : RuleActionHandler + { + private const string Description = "Send a tweet"; + + private readonly TwitterOptions twitterOptions; + + public TweetActionHandler(RuleEventFormatter formatter, IOptions twitterOptions) + : base(formatter) + { + Guard.NotNull(twitterOptions, nameof(twitterOptions)); + + this.twitterOptions = twitterOptions.Value; + } + + protected override (string Description, TweetJob Data) CreateJob(EnrichedEvent @event, TweetAction action) + { + var ruleJob = new TweetJob + { + Text = Format(action.Text, @event), + AccessToken = action.AccessToken, + AccessSecret = action.AccessSecret + }; + + return (Description, ruleJob); + } + + protected override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(TweetJob job) + { + var tokens = Tokens.Create( + twitterOptions.ClientId, + twitterOptions.ClientSecret, + job.AccessToken, + job.AccessSecret); + + await tokens.Statuses.UpdateAsync(status => job.Text); + + return ($"Tweeted: {job.Text}", null); + } + } + + public sealed class TweetJob + { + public string AccessToken { get; set; } + + public string AccessSecret { get; set; } + + public string Text { get; set; } + } +} diff --git a/extensions/Squidex.Extensions/Actions/Twitter/TwitterOptions.cs b/extensions/Squidex.Extensions/Actions/Twitter/TwitterOptions.cs new file mode 100644 index 000000000..d602e7099 --- /dev/null +++ b/extensions/Squidex.Extensions/Actions/Twitter/TwitterOptions.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Extensions.Actions.Twitter +{ + public sealed class TwitterOptions + { + public string ClientId { get; set; } + + public string ClientSecret { get; set; } + + public bool IsConfigured() + { + return !string.IsNullOrWhiteSpace(ClientId) && !string.IsNullOrWhiteSpace(ClientSecret); + } + } +} diff --git a/extensions/Squidex.Extensions/Actions/Webhook/WebhookAction.cs b/extensions/Squidex.Extensions/Actions/Webhook/WebhookAction.cs new file mode 100644 index 000000000..e2c260b11 --- /dev/null +++ b/extensions/Squidex.Extensions/Actions/Webhook/WebhookAction.cs @@ -0,0 +1,32 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Infrastructure; + +namespace Squidex.Extensions.Actions.Webhook +{ + [RuleActionHandler(typeof(WebhookActionHandler))] + [RuleAction( + IconImage = "", + IconColor = "#4bb958", + Display = "Send webhook", + Description = "Send events like ContentPublished to your webhook.", + ReadMore = "https://en.wikipedia.org/wiki/Webhook")] + public sealed class WebhookAction : RuleAction + { + [AbsoluteUrl] + [Required] + [Display(Name = "Url", Description = "he url to the webhook.")] + public Uri Url { get; set; } + + [Display(Name = "Shared Secret", Description = "The shared secret that is used to calculate the signature.")] + public string SharedSecret { get; set; } + } +} diff --git a/extensions/Squidex.Extensions/Actions/Webhook/WebhookActionHandler.cs b/extensions/Squidex.Extensions/Actions/Webhook/WebhookActionHandler.cs new file mode 100644 index 000000000..4220820ea --- /dev/null +++ b/extensions/Squidex.Extensions/Actions/Webhook/WebhookActionHandler.cs @@ -0,0 +1,72 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents; +using Squidex.Infrastructure; + +namespace Squidex.Extensions.Actions.Webhook +{ + public sealed class WebhookActionHandler : RuleActionHandler + { + private readonly IHttpClientFactory httpClientFactory; + + public WebhookActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory) + : base(formatter) + { + Guard.NotNull(httpClientFactory, nameof(httpClientFactory)); + + this.httpClientFactory = httpClientFactory; + } + + protected override (string Description, WebhookJob Data) CreateJob(EnrichedEvent @event, WebhookAction action) + { + var requestBody = ToEnvelopeJson(@event); + var requestUrl = Format(action.Url, @event); + + var ruleDescription = $"Send event to webhook '{requestUrl}'"; + var ruleJob = new WebhookJob + { + RequestUrl = Format(action.Url.ToString(), @event), + RequestSignature = $"{requestBody}{action.SharedSecret}".Sha256Base64(), + RequestBody = requestBody + }; + + return (ruleDescription, ruleJob); + } + + protected override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(WebhookJob job) + { + using (var httpClient = httpClientFactory.CreateClient()) + { + var request = new HttpRequestMessage(HttpMethod.Post, job.RequestUrl) + { + Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json") + }; + + request.Headers.Add("X-Signature", job.RequestSignature); + request.Headers.Add("X-Application", "Squidex Webhook"); + request.Headers.Add("User-Agent", "Squidex Webhook"); + + return await httpClient.OneWayRequestAsync(request, job.RequestBody); + } + } + } + + public sealed class WebhookJob + { + public string RequestUrl { get; set; } + + public string RequestSignature { get; set; } + + public string RequestBody { get; set; } + } +} diff --git a/extensions/Squidex.Extensions/Squidex.Extensions.csproj b/extensions/Squidex.Extensions/Squidex.Extensions.csproj new file mode 100644 index 000000000..5e0af3773 --- /dev/null +++ b/extensions/Squidex.Extensions/Squidex.Extensions.csproj @@ -0,0 +1,33 @@ + + + netstandard2.0 + + + full + True + + + + + + + + + + + + + + + + + + + + + ..\..\Squidex.ruleset + + + + + diff --git a/libs/Dockerfile b/libs/Dockerfile deleted file mode 100644 index c10edc3b2..000000000 --- a/libs/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -FROM microsoft/aspnetcore-build:2.0.3-jessie - -# Install runtime dependencies -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates bzip2 libfontconfig \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - - # Install official PhantomJS release -RUN set -x \ - && apt-get update \ - && apt-get install -y --no-install-recommends \ - && mkdir /srv/var \ - && mkdir /tmp/phantomjs \ - # Download Phantom JS - && curl -L https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-2.1.1-linux-x86_64.tar.bz2 | tar -xj --strip-components=1 -C /tmp/phantomjs \ - # Copy binaries only - && mv /tmp/phantomjs/bin/phantomjs /usr/local/bin \ - # Create symbol link - # Clean up - && apt-get autoremove -y \ - && apt-get clean all \ - && rm -rf /tmp/* /var/lib/apt/lists/* - -RUN phantomjs --version \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/LanguagesConfig.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/LanguagesConfig.cs index be1dbe7f9..51003278f 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Apps/LanguagesConfig.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Apps/LanguagesConfig.cs @@ -17,7 +17,6 @@ namespace Squidex.Domain.Apps.Core.Apps { public sealed class LanguagesConfig : IFieldPartitioning { - public static readonly LanguagesConfig Empty = new LanguagesConfig(ImmutableDictionary.Empty, null, false); public static readonly LanguagesConfig English = Build(Language.EN); private readonly ImmutableDictionary languages; @@ -111,13 +110,10 @@ namespace Squidex.Domain.Apps.Core.Apps var newLanguages = languages.Values.Where(x => x.Language != language) - .Select(config => - { - return new LanguageConfig( - config.Language, - config.IsOptional, - config.LanguageFallbacks.Except(new[] { language })); - }) + .Select(config => new LanguageConfig( + config.Language, + config.IsOptional, + config.LanguageFallbacks.Except(new[] { language }))) .ToImmutableDictionary(x => x.Language); var newMaster = diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/ContentData.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/ContentData.cs index 0258998e9..2ebc1d054 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Contents/ContentData.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/ContentData.cs @@ -25,8 +25,8 @@ namespace Squidex.Domain.Apps.Core.Contents { } - protected ContentData(IDictionary copy, IEqualityComparer comparer) - : base(copy, comparer) + protected ContentData(int capacity, IEqualityComparer comparer) + : base(capacity, comparer) { } @@ -43,7 +43,7 @@ namespace Squidex.Domain.Apps.Core.Contents { foreach (var otherValue in source) { - var fieldValue = target.GetOrAdd(otherValue.Key, x => new ContentFieldData()); + var fieldValue = target.GetOrAddNew(otherValue.Key); foreach (var value in otherValue.Value) { diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/IdContentData.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/IdContentData.cs index 6398d66d4..0ca33663e 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Contents/IdContentData.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/IdContentData.cs @@ -18,8 +18,8 @@ namespace Squidex.Domain.Apps.Core.Contents { } - public IdContentData(IdContentData copy) - : base(copy, EqualityComparer.Default) + public IdContentData(int capacity) + : base(capacity, EqualityComparer.Default) { } diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/NamedContentData.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/NamedContentData.cs index b7e4187a5..fd298d5ef 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Contents/NamedContentData.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/NamedContentData.cs @@ -6,7 +6,6 @@ // ========================================================================== using System; -using System.Collections.Generic; using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Contents @@ -18,8 +17,8 @@ namespace Squidex.Domain.Apps.Core.Contents { } - public NamedContentData(NamedContentData copy) - : base(copy, EqualityComparer.Default) + public NamedContentData(int capacity) + : base(capacity, StringComparer.OrdinalIgnoreCase) { } diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/StatusChange.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/StatusChange.cs new file mode 100644 index 000000000..9e3900deb --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/StatusChange.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Core.Contents +{ + public enum StatusChange + { + Archived, + Published, + Restored, + Unpublished + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Partitioning.cs b/src/Squidex.Domain.Apps.Core.Model/Partitioning.cs index f6600ede5..8190674f1 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Partitioning.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Partitioning.cs @@ -45,5 +45,12 @@ namespace Squidex.Domain.Apps.Core { return Key; } + + public static Partitioning FromString(string value) + { + var isLanguage = string.Equals(value, Language.Key, StringComparison.OrdinalIgnoreCase); + + return isLanguage ? Language : Invariant; + } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/AlgoliaAction.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/AlgoliaAction.cs deleted file mode 100644 index 33295be4a..000000000 --- a/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/AlgoliaAction.cs +++ /dev/null @@ -1,26 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core.Rules.Actions -{ - [TypeName(nameof(AlgoliaAction))] - public sealed class AlgoliaAction : RuleAction - { - public string AppId { get; set; } - - public string ApiKey { get; set; } - - public string IndexName { get; set; } - - public override T Accept(IRuleActionVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/AzureQueueAction.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/AzureQueueAction.cs deleted file mode 100644 index fc9178243..000000000 --- a/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/AzureQueueAction.cs +++ /dev/null @@ -1,24 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core.Rules.Actions -{ - [TypeName(nameof(AzureQueueAction))] - public sealed class AzureQueueAction : RuleAction - { - public string ConnectionString { get; set; } - - public string Queue { get; set; } - - public override T Accept(IRuleActionVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/ElasticSearchAction.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/ElasticSearchAction.cs deleted file mode 100644 index e3623f25a..000000000 --- a/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/ElasticSearchAction.cs +++ /dev/null @@ -1,31 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core.Rules.Actions -{ - [TypeName(nameof(ElasticSearchAction))] - public sealed class ElasticSearchAction : RuleAction - { - public Uri Host { get; set; } - - public string Username { get; set; } - - public string Password { get; set; } - - public string IndexName { get; set; } - - public string IndexType { get; set; } - - public override T Accept(IRuleActionVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/FastlyAction.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/FastlyAction.cs deleted file mode 100644 index 2d459d500..000000000 --- a/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/FastlyAction.cs +++ /dev/null @@ -1,24 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core.Rules.Actions -{ - [TypeName(nameof(FastlyAction))] - public sealed class FastlyAction : RuleAction - { - public string ApiKey { get; set; } - - public string ServiceId { get; set; } - - public override T Accept(IRuleActionVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/SlackAction.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/SlackAction.cs deleted file mode 100644 index b669fe104..000000000 --- a/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/SlackAction.cs +++ /dev/null @@ -1,25 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core.Rules.Actions -{ - [TypeName(nameof(SlackAction))] - public sealed class SlackAction : RuleAction - { - public Uri WebhookUrl { get; set; } - - public string Text { get; set; } - - public override T Accept(IRuleActionVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/WebhookAction.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/WebhookAction.cs deleted file mode 100644 index 30a6c0707..000000000 --- a/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/WebhookAction.cs +++ /dev/null @@ -1,25 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core.Rules.Actions -{ - [TypeName(nameof(WebhookAction))] - public sealed class WebhookAction : RuleAction - { - public Uri Url { get; set; } - - public string SharedSecret { get; set; } - - public override T Accept(IRuleActionVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/IRuleActionVisitor.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/IRuleActionVisitor.cs deleted file mode 100644 index 2ef2e3516..000000000 --- a/src/Squidex.Domain.Apps.Core.Model/Rules/IRuleActionVisitor.cs +++ /dev/null @@ -1,26 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Squidex.Domain.Apps.Core.Rules.Actions; - -namespace Squidex.Domain.Apps.Core.Rules -{ - public interface IRuleActionVisitor - { - T Visit(AlgoliaAction action); - - T Visit(AzureQueueAction action); - - T Visit(ElasticSearchAction action); - - T Visit(FastlyAction action); - - T Visit(SlackAction action); - - T Visit(WebhookAction action); - } -} diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/Json/JsonRule.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/Json/JsonRule.cs index ce63262a9..8cf13f2a3 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Rules/Json/JsonRule.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Rules/Json/JsonRule.cs @@ -36,7 +36,7 @@ namespace Squidex.Domain.Apps.Core.Rules.Json if (!IsEnabled) { - rule.Disable(); + rule = rule.Disable(); } return rule; diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/Rule.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/Rule.cs index be7bed2ca..e0f3b3618 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Rules/Rule.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Rules/Rule.cs @@ -38,7 +38,10 @@ namespace Squidex.Domain.Apps.Core.Rules Guard.NotNull(action, nameof(action)); this.trigger = trigger; + this.trigger.Freeze(); + this.action = action; + this.action.Freeze(); } [Pure] @@ -69,6 +72,8 @@ namespace Squidex.Domain.Apps.Core.Rules throw new ArgumentException("New trigger has another type.", nameof(newTrigger)); } + newTrigger.Freeze(); + return Clone(clone => { clone.trigger = newTrigger; @@ -85,6 +90,8 @@ namespace Squidex.Domain.Apps.Core.Rules throw new ArgumentException("New action has another type.", nameof(newAction)); } + newAction.Freeze(); + return Clone(clone => { clone.action = newAction; diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/RuleAction.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/RuleAction.cs index caaf409d0..10c8be123 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Rules/RuleAction.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Rules/RuleAction.cs @@ -5,10 +5,37 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Squidex.Infrastructure; + namespace Squidex.Domain.Apps.Core.Rules { public abstract class RuleAction : Freezable { - public abstract T Accept(IRuleActionVisitor visitor); + public IEnumerable Validate() + { + var context = new ValidationContext(this); + var errors = new List(); + + if (!Validator.TryValidateObject(this, context, errors, true)) + { + foreach (var error in errors) + { + yield return new ValidationError(error.ErrorMessage, error.MemberNames.ToArray()); + } + } + + foreach (var error in CustomValidate()) + { + yield return error; + } + } + + protected virtual IEnumerable CustomValidate() + { + yield break; + } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/RuleJob.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/RuleJob.cs index 963832075..c83cf9626 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Rules/RuleJob.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Rules/RuleJob.cs @@ -6,6 +6,7 @@ // ========================================================================== using System; +using Newtonsoft.Json.Linq; using NodaTime; namespace Squidex.Domain.Apps.Core.Rules @@ -28,6 +29,6 @@ namespace Squidex.Domain.Apps.Core.Rules public Instant Expires { get; set; } - public RuleJobData ActionData { get; set; } + public JObject ActionData { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/RuleJobData.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/RuleJobData.cs deleted file mode 100644 index 59fb244d7..000000000 --- a/src/Squidex.Domain.Apps.Core.Model/Rules/RuleJobData.cs +++ /dev/null @@ -1,16 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Collections.Generic; -using Newtonsoft.Json.Linq; - -namespace Squidex.Domain.Apps.Core.Rules -{ - public sealed class RuleJobData : Dictionary - { - } -} diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerSchema.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerSchema.cs index 33d073f5a..cfcb516d1 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerSchema.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerSchema.cs @@ -20,5 +20,11 @@ namespace Squidex.Domain.Apps.Core.Rules.Triggers public bool SendDelete { get; set; } public bool SendPublish { get; set; } + + public bool SendUnpublish { get; set; } + + public bool SendArchived { get; set; } + + public bool SendRestore { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayField.cs new file mode 100644 index 000000000..7a74a39f0 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayField.cs @@ -0,0 +1,77 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Diagnostics.Contracts; + +namespace Squidex.Domain.Apps.Core.Schemas +{ + public sealed class ArrayField : RootField, IArrayField + { + private FieldCollection fields = FieldCollection.Empty; + + public IReadOnlyList Fields + { + get { return fields.Ordered; } + } + + public IReadOnlyDictionary FieldsById + { + get { return fields.ById; } + } + + public IReadOnlyDictionary FieldsByName + { + get { return fields.ByName; } + } + + public ArrayField(long id, string name, Partitioning partitioning, ArrayFieldProperties properties) + : base(id, name, partitioning, properties) + { + } + + [Pure] + public ArrayField DeleteField(long fieldId) + { + return Updatefields(f => f.Remove(fieldId)); + } + + [Pure] + public ArrayField ReorderFields(List ids) + { + return Updatefields(f => f.Reorder(ids)); + } + + [Pure] + public ArrayField AddField(NestedField field) + { + return Updatefields(f => f.Add(field)); + } + + [Pure] + public ArrayField UpdateField(long fieldId, Func updater) + { + return Updatefields(f => f.Update(fieldId, updater)); + } + + private ArrayField Updatefields(Func, FieldCollection> updater) + { + var newFields = updater(fields); + + if (ReferenceEquals(newFields, fields)) + { + return this; + } + + return Clone(clone => + { + clone.fields = newFields; + }); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayFieldProperties.cs new file mode 100644 index 000000000..f3f6100d9 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayFieldProperties.cs @@ -0,0 +1,40 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.Schemas +{ + [TypeName("ArrayField")] + public sealed class ArrayFieldProperties : FieldProperties + { + public int? MinItems { get; set; } + + public int? MaxItems { get; set; } + + public override T Accept(IFieldPropertiesVisitor visitor) + { + return visitor.Visit(this); + } + + public override T Accept(IFieldVisitor visitor, IField field) + { + return visitor.Visit((IArrayField)field); + } + + public override RootField CreateRootField(long id, string name, Partitioning partitioning) + { + return Fields.Array(id, name, partitioning, this); + } + + public override NestedField CreateNestedField(long id, string name) + { + throw new NotSupportedException(); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/AssetsField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/AssetsField.cs deleted file mode 100644 index fd769b013..000000000 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/AssetsField.cs +++ /dev/null @@ -1,27 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public sealed class AssetsField : Field - { - public AssetsField(long id, string name, Partitioning partitioning) - : base(id, name, partitioning, new AssetsFieldProperties()) - { - } - - public AssetsField(long id, string name, Partitioning partitioning, AssetsFieldProperties properties) - : base(id, name, partitioning, properties) - { - } - - public override T Accept(IFieldVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/AssetsFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/AssetsFieldProperties.cs index d3ed6270a..62b3be7c1 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/AssetsFieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/AssetsFieldProperties.cs @@ -10,7 +10,7 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Schemas { - [TypeName(nameof(AssetsField))] + [TypeName("AssetsField")] public sealed class AssetsFieldProperties : FieldProperties { public bool MustBeImage { get; set; } @@ -42,9 +42,19 @@ namespace Squidex.Domain.Apps.Core.Schemas return visitor.Visit(this); } - public override Field CreateField(long id, string name, Partitioning partitioning) + public override T Accept(IFieldVisitor visitor, IField field) { - return new AssetsField(id, name, partitioning, this); + return visitor.Visit((IField)field); + } + + public override RootField CreateRootField(long id, string name, Partitioning partitioning) + { + return Fields.Assets(id, name, partitioning, this); + } + + public override NestedField CreateNestedField(long id, string name) + { + return Fields.Assets(id, name, this); } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/BooleanField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/BooleanField.cs deleted file mode 100644 index fc590e022..000000000 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/BooleanField.cs +++ /dev/null @@ -1,27 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public sealed class BooleanField : Field - { - public BooleanField(long id, string name, Partitioning partitioning) - : base(id, name, partitioning, new BooleanFieldProperties()) - { - } - - public BooleanField(long id, string name, Partitioning partitioning, BooleanFieldProperties properties) - : base(id, name, partitioning, properties) - { - } - - public override T Accept(IFieldVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/BooleanFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/BooleanFieldProperties.cs index 531507e37..a4a0750a5 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/BooleanFieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/BooleanFieldProperties.cs @@ -9,7 +9,7 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Schemas { - [TypeName(nameof(BooleanField))] + [TypeName("BooleanField")] public sealed class BooleanFieldProperties : FieldProperties { public bool? DefaultValue { get; set; } @@ -23,9 +23,19 @@ namespace Squidex.Domain.Apps.Core.Schemas return visitor.Visit(this); } - public override Field CreateField(long id, string name, Partitioning partitioning) + public override T Accept(IFieldVisitor visitor, IField field) { - return new BooleanField(id, name, partitioning, this); + return visitor.Visit((IField)field); + } + + public override RootField CreateRootField(long id, string name, Partitioning partitioning) + { + return Fields.Boolean(id, name, partitioning, this); + } + + public override NestedField CreateNestedField(long id, string name) + { + return Fields.Boolean(id, name, this); } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/DateTimeField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/DateTimeField.cs deleted file mode 100644 index 5c4cf1a1e..000000000 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/DateTimeField.cs +++ /dev/null @@ -1,27 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public sealed class DateTimeField : Field - { - public DateTimeField(long id, string name, Partitioning partitioning) - : base(id, name, partitioning, new DateTimeFieldProperties()) - { - } - - public DateTimeField(long id, string name, Partitioning partitioning, DateTimeFieldProperties properties) - : base(id, name, partitioning, properties) - { - } - - public override T Accept(IFieldVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/DateTimeFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/DateTimeFieldProperties.cs index 8dcdd85d5..efbcad12b 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/DateTimeFieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/DateTimeFieldProperties.cs @@ -10,7 +10,7 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Schemas { - [TypeName(nameof(DateTimeField))] + [TypeName("DateTimeField")] public sealed class DateTimeFieldProperties : FieldProperties { public Instant? MaxValue { get; set; } @@ -28,9 +28,19 @@ namespace Squidex.Domain.Apps.Core.Schemas return visitor.Visit(this); } - public override Field CreateField(long id, string name, Partitioning partitioning) + public override T Accept(IFieldVisitor visitor, IField field) { - return new DateTimeField(id, name, partitioning, this); + return visitor.Visit((IField)field); + } + + public override RootField CreateRootField(long id, string name, Partitioning partitioning) + { + return Fields.DateTime(id, name, partitioning, this); + } + + public override NestedField CreateNestedField(long id, string name) + { + return Fields.DateTime(id, name, this); } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/Field.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/Field.cs deleted file mode 100644 index 073c01a42..000000000 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/Field.cs +++ /dev/null @@ -1,115 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Diagnostics.Contracts; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public abstract class Field : Cloneable - { - private readonly long fieldId; - private readonly Partitioning partitioning; - private readonly string fieldName; - private bool isDisabled; - private bool isHidden; - private bool isLocked; - - public long Id - { - get { return fieldId; } - } - - public string Name - { - get { return fieldName; } - } - - public bool IsLocked - { - get { return isLocked; } - } - - public bool IsHidden - { - get { return isHidden; } - } - - public bool IsDisabled - { - get { return isDisabled; } - } - - public Partitioning Partitioning - { - get { return partitioning; } - } - - public abstract FieldProperties RawProperties { get; } - - protected Field(long id, string name, Partitioning partitioning) - { - Guard.NotNullOrEmpty(name, nameof(name)); - Guard.NotNull(partitioning, nameof(partitioning)); - Guard.GreaterThan(id, 0, nameof(id)); - - fieldId = id; - fieldName = name; - - this.partitioning = partitioning; - } - - [Pure] - public Field Lock() - { - return Clone(clone => - { - clone.isLocked = true; - }); - } - - [Pure] - public Field Hide() - { - return Clone(clone => - { - clone.isHidden = true; - }); - } - - [Pure] - public Field Show() - { - return Clone(clone => - { - clone.isHidden = false; - }); - } - - [Pure] - public Field Disable() - { - return Clone(clone => - { - clone.isDisabled = true; - }); - } - - [Pure] - public Field Enable() - { - return Clone(clone => - { - clone.isDisabled = false; - }); - } - - public abstract Field Update(FieldProperties newProperties); - - public abstract T Accept(IFieldVisitor visitor); - } -} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs new file mode 100644 index 000000000..e1546841d --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs @@ -0,0 +1,161 @@ +// ========================================================================== +// 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.Collections.Immutable; +using System.Diagnostics.Contracts; +using System.Linq; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.Schemas +{ + public sealed class FieldCollection : Cloneable> where T : IField + { + public static readonly FieldCollection Empty = new FieldCollection(); + + private ImmutableArray fieldsOrdered = ImmutableArray.Empty; + private ImmutableDictionary fieldsById; + private ImmutableDictionary fieldsByName; + + public IReadOnlyList Ordered + { + get { return fieldsOrdered; } + } + + public IReadOnlyDictionary ById + { + get + { + if (fieldsById == null) + { + if (fieldsOrdered.Length == 0) + { + fieldsById = ImmutableDictionary.Empty; + } + else + { + fieldsById = fieldsOrdered.ToImmutableDictionary(x => x.Id); + } + } + + return fieldsById; + } + } + + public IReadOnlyDictionary ByName + { + get + { + if (fieldsByName == null) + { + if (fieldsOrdered.Length == 0) + { + fieldsByName = ImmutableDictionary.Empty; + } + else + { + fieldsByName = fieldsOrdered.ToImmutableDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase); + } + } + + return fieldsByName; + } + } + + private FieldCollection() + { + } + + public FieldCollection(T[] fields) + { + Guard.NotNull(fields, nameof(fields)); + + fieldsOrdered = ImmutableArray.Create(fields); + } + + protected override void OnCloned() + { + fieldsById = null; + fieldsByName = null; + } + + [Pure] + public FieldCollection Remove(long fieldId) + { + if (!ById.TryGetValue(fieldId, out var field)) + { + return this; + } + + return Clone(clone => + { + clone.fieldsOrdered = fieldsOrdered.Remove(field); + }); + } + + [Pure] + public FieldCollection Reorder(List ids) + { + Guard.NotNull(ids, nameof(ids)); + + if (ids.Count != fieldsOrdered.Length || ids.Any(x => !ById.ContainsKey(x))) + { + throw new ArgumentException("Ids must cover all fields.", nameof(ids)); + } + + return Clone(clone => + { + clone.fieldsOrdered = fieldsOrdered.OrderBy(f => ids.IndexOf(f.Id)).ToImmutableArray(); + }); + } + + [Pure] + public FieldCollection Add(T field) + { + Guard.NotNull(field, nameof(field)); + + if (ByName.ContainsKey(field.Name) || ById.ContainsKey(field.Id)) + { + throw new ArgumentException($"A field with name '{field.Name}' and id {field.Id} already exists.", nameof(field)); + } + + return Clone(clone => + { + clone.fieldsOrdered = clone.fieldsOrdered.Add(field); + }); + } + + [Pure] + public FieldCollection Update(long fieldId, Func updater) + { + Guard.NotNull(updater, nameof(updater)); + + if (!ById.TryGetValue(fieldId, out var field)) + { + return this; + } + + var newField = updater(field); + + if (ReferenceEquals(newField, field)) + { + return this; + } + + if (!(newField is T typedField)) + { + throw new InvalidOperationException($"Field must be of type {typeof(T)}"); + } + + return Clone(clone => + { + clone.fieldsOrdered = clone.fieldsOrdered.Replace(field, typedField); + }); + } + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldExtensions.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldExtensions.cs new file mode 100644 index 000000000..bf34e0911 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldExtensions.cs @@ -0,0 +1,158 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; + +namespace Squidex.Domain.Apps.Core.Schemas +{ + public static class FieldExtensions + { + public static Schema ReorderFields(this Schema schema, List ids, long? parentId = null) + { + if (parentId != null) + { + return schema.UpdateField(parentId.Value, f => + { + if (f is ArrayField arrayField) + { + return arrayField.ReorderFields(ids); + } + + return f; + }); + } + + return schema.ReorderFields(ids); + } + + public static Schema DeleteField(this Schema schema, long fieldId, long? parentId = null) + { + if (parentId != null) + { + return schema.UpdateField(parentId.Value, f => + { + if (f is ArrayField arrayField) + { + return arrayField.DeleteField(fieldId); + } + + return f; + }); + } + + return schema.DeleteField(fieldId); + } + + public static Schema LockField(this Schema schema, long fieldId, long? parentId = null) + { + if (parentId != null) + { + return schema.UpdateField(parentId.Value, f => + { + if (f is ArrayField arrayField) + { + return arrayField.UpdateField(fieldId, n => n.Lock()); + } + + return f; + }); + } + + return schema.UpdateField(fieldId, f => f.Lock()); + } + + public static Schema HideField(this Schema schema, long fieldId, long? parentId = null) + { + if (parentId != null) + { + return schema.UpdateField(parentId.Value, f => + { + if (f is ArrayField arrayField) + { + return arrayField.UpdateField(fieldId, n => n.Hide()); + } + + return f; + }); + } + + return schema.UpdateField(fieldId, f => f.Hide()); + } + + public static Schema ShowField(this Schema schema, long fieldId, long? parentId = null) + { + if (parentId != null) + { + return schema.UpdateField(parentId.Value, f => + { + if (f is ArrayField arrayField) + { + return arrayField.UpdateField(fieldId, n => n.Show()); + } + + return f; + }); + } + + return schema.UpdateField(fieldId, f => f.Show()); + } + + public static Schema EnableField(this Schema schema, long fieldId, long? parentId = null) + { + if (parentId != null) + { + return schema.UpdateField(parentId.Value, f => + { + if (f is ArrayField arrayField) + { + return arrayField.UpdateField(fieldId, n => n.Enable()); + } + + return f; + }); + } + + return schema.UpdateField(fieldId, f => f.Enable()); + } + + public static Schema DisableField(this Schema schema, long fieldId, long? parentId = null) + { + if (parentId != null) + { + return schema.UpdateField(parentId.Value, f => + { + if (f is ArrayField arrayField) + { + return arrayField.UpdateField(fieldId, n => n.Disable()); + } + + return f; + }); + } + + return schema.UpdateField(fieldId, f => f.Disable()); + } + + public static Schema UpdateField(this Schema schema, long fieldId, FieldProperties properties, long? parentId = null) + { + if (parentId != null) + { + return schema.UpdateField(parentId.Value, f => + { + if (f is ArrayField arrayField) + { + return arrayField.UpdateField(fieldId, n => n.Update(properties)); + } + + return f; + }); + } + + return schema.UpdateField(fieldId, f => f.Update(properties)); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldProperties.cs index 3820bd6c5..a9c8d0421 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldProperties.cs @@ -15,8 +15,14 @@ namespace Squidex.Domain.Apps.Core.Schemas public string Placeholder { get; set; } + public string EditorUrl { get; set; } + public abstract T Accept(IFieldPropertiesVisitor visitor); - public abstract Field CreateField(long id, string name, Partitioning partitioning); + public abstract T Accept(IFieldVisitor visitor, IField field); + + public abstract RootField CreateRootField(long id, string name, Partitioning partitioning); + + public abstract NestedField CreateNestedField(long id, string name); } } \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldRegistry.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldRegistry.cs index 7d1a61635..2cc7be0de 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldRegistry.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldRegistry.cs @@ -7,16 +7,15 @@ using System; using System.Collections.Generic; +using System.Linq; using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Schemas { public sealed class FieldRegistry { - private delegate Field FactoryFunction(long id, string name, Partitioning partitioning, FieldProperties properties); - private readonly TypeNameRegistry typeNameRegistry; - private readonly Dictionary fieldsByPropertyType = new Dictionary(); + private readonly HashSet supportedFields = new HashSet(); public FieldRegistry(TypeNameRegistry typeNameRegistry) { @@ -24,39 +23,47 @@ namespace Squidex.Domain.Apps.Core.Schemas this.typeNameRegistry = typeNameRegistry; - RegisterField(); - RegisterField(); - RegisterField(); - RegisterField(); - RegisterField(); - RegisterField(); - RegisterField(); - RegisterField(); - RegisterField(); - - typeNameRegistry.MapObsolete(typeof(ReferencesFieldProperties), "DateTime"); - typeNameRegistry.MapObsolete(typeof(DateTimeFieldProperties), "References"); + var types = typeof(FieldRegistry).Assembly.GetTypes().Where(x => x.BaseType == typeof(FieldProperties)); + + foreach (var type in types) + { + RegisterField(type); + } + + typeNameRegistry.MapObsolete(typeof(ReferencesFieldProperties), "References"); + typeNameRegistry.MapObsolete(typeof(DateTimeFieldProperties), "DateTime"); + } + + private void RegisterField(Type type) + { + if (supportedFields.Add(type)) + { + typeNameRegistry.Map(type); + } + } + + public RootField CreateRootField(long id, string name, Partitioning partitioning, FieldProperties properties) + { + CheckProperties(properties); + + return properties.CreateRootField(id, name, partitioning); } - private void RegisterField() + public NestedField CreateNestedField(long id, string name, FieldProperties properties) { - typeNameRegistry.Map(typeof(T)); + CheckProperties(properties); - fieldsByPropertyType[typeof(T)] = (id, name, partitioning, properties) => properties.CreateField(id, name, partitioning); + return properties.CreateNestedField(id, name); } - public Field CreateField(long id, string name, Partitioning partitioning, FieldProperties properties) + private void CheckProperties(FieldProperties properties) { Guard.NotNull(properties, nameof(properties)); - var factory = fieldsByPropertyType.GetOrDefault(properties.GetType()); - - if (factory == null) + if (!supportedFields.Contains(properties.GetType())) { throw new InvalidOperationException($"The field property '{properties.GetType()}' is not supported."); } - - return factory(id, name, partitioning, properties); } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/Fields.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/Fields.cs new file mode 100644 index 000000000..de6e49e28 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/Fields.cs @@ -0,0 +1,226 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; + +namespace Squidex.Domain.Apps.Core.Schemas +{ + public static class Fields + { + public static RootField Array(long id, string name, Partitioning partitioning, params NestedField[] fields) + { + var result = new ArrayField(id, name, partitioning, new ArrayFieldProperties()); + + if (fields != null) + { + foreach (var field in fields) + { + result = result.AddField(field); + } + } + + return result; + } + + public static ArrayField Array(long id, string name, Partitioning partitioning, ArrayFieldProperties properties = null) + { + return new ArrayField(id, name, partitioning, properties ?? new ArrayFieldProperties()); + } + + public static RootField Assets(long id, string name, Partitioning partitioning, AssetsFieldProperties properties = null) + { + return new RootField(id, name, partitioning, properties ?? new AssetsFieldProperties()); + } + + public static RootField Boolean(long id, string name, Partitioning partitioning, BooleanFieldProperties properties = null) + { + return new RootField(id, name, partitioning, properties ?? new BooleanFieldProperties()); + } + + public static RootField DateTime(long id, string name, Partitioning partitioning, DateTimeFieldProperties properties = null) + { + return new RootField(id, name, partitioning, properties ?? new DateTimeFieldProperties()); + } + + public static RootField Geolocation(long id, string name, Partitioning partitioning, GeolocationFieldProperties properties = null) + { + return new RootField(id, name, partitioning, properties ?? new GeolocationFieldProperties()); + } + + public static RootField Json(long id, string name, Partitioning partitioning, JsonFieldProperties properties = null) + { + return new RootField(id, name, partitioning, properties ?? new JsonFieldProperties()); + } + + public static RootField Number(long id, string name, Partitioning partitioning, NumberFieldProperties properties = null) + { + return new RootField(id, name, partitioning, properties ?? new NumberFieldProperties()); + } + + public static RootField References(long id, string name, Partitioning partitioning, ReferencesFieldProperties properties = null) + { + return new RootField(id, name, partitioning, properties ?? new ReferencesFieldProperties()); + } + + public static RootField String(long id, string name, Partitioning partitioning, StringFieldProperties properties = null) + { + return new RootField(id, name, partitioning, properties ?? new StringFieldProperties()); + } + + public static RootField Tags(long id, string name, Partitioning partitioning, TagsFieldProperties properties = null) + { + return new RootField(id, name, partitioning, properties ?? new TagsFieldProperties()); + } + + public static NestedField Assets(long id, string name, AssetsFieldProperties properties = null) + { + return new NestedField(id, name, properties ?? new AssetsFieldProperties()); + } + + public static NestedField Boolean(long id, string name, BooleanFieldProperties properties = null) + { + return new NestedField(id, name, properties ?? new BooleanFieldProperties()); + } + + public static NestedField DateTime(long id, string name, DateTimeFieldProperties properties = null) + { + return new NestedField(id, name, properties ?? new DateTimeFieldProperties()); + } + + public static NestedField Geolocation(long id, string name, GeolocationFieldProperties properties = null) + { + return new NestedField(id, name, properties ?? new GeolocationFieldProperties()); + } + + public static NestedField Json(long id, string name, JsonFieldProperties properties = null) + { + return new NestedField(id, name, properties ?? new JsonFieldProperties()); + } + + public static NestedField Number(long id, string name, NumberFieldProperties properties = null) + { + return new NestedField(id, name, properties ?? new NumberFieldProperties()); + } + + public static NestedField References(long id, string name, ReferencesFieldProperties properties = null) + { + return new NestedField(id, name, properties ?? new ReferencesFieldProperties()); + } + + public static NestedField String(long id, string name, StringFieldProperties properties = null) + { + return new NestedField(id, name, properties ?? new StringFieldProperties()); + } + + public static NestedField Tags(long id, string name, TagsFieldProperties properties = null) + { + return new NestedField(id, name, properties ?? new TagsFieldProperties()); + } + + public static Schema AddArray(this Schema schema, long id, string name, Partitioning partitioning, Func handler, ArrayFieldProperties properties = null) + { + var field = Array(id, name, partitioning, properties); + + if (handler != null) + { + field = handler(field); + } + + return schema.AddField(field); + } + + public static Schema AddAssets(this Schema schema, long id, string name, Partitioning partitioning, AssetsFieldProperties properties = null) + { + return schema.AddField(Assets(id, name, partitioning, properties)); + } + + public static Schema AddBoolean(this Schema schema, long id, string name, Partitioning partitioning, BooleanFieldProperties properties = null) + { + return schema.AddField(Boolean(id, name, partitioning, properties)); + } + + public static Schema AddDateTime(this Schema schema, long id, string name, Partitioning partitioning, DateTimeFieldProperties properties = null) + { + return schema.AddField(DateTime(id, name, partitioning, properties)); + } + + public static Schema AddGeolocation(this Schema schema, long id, string name, Partitioning partitioning, GeolocationFieldProperties properties = null) + { + return schema.AddField(Geolocation(id, name, partitioning, properties)); + } + + public static Schema AddJson(this Schema schema, long id, string name, Partitioning partitioning, JsonFieldProperties properties = null) + { + return schema.AddField(Json(id, name, partitioning, properties)); + } + + public static Schema AddNumber(this Schema schema, long id, string name, Partitioning partitioning, NumberFieldProperties properties = null) + { + return schema.AddField(Number(id, name, partitioning, properties)); + } + + public static Schema AddReferences(this Schema schema, long id, string name, Partitioning partitioning, ReferencesFieldProperties properties = null) + { + return schema.AddField(References(id, name, partitioning, properties)); + } + + public static Schema AddString(this Schema schema, long id, string name, Partitioning partitioning, StringFieldProperties properties = null) + { + return schema.AddField(String(id, name, partitioning, properties)); + } + + public static Schema AddTags(this Schema schema, long id, string name, Partitioning partitioning, TagsFieldProperties properties = null) + { + return schema.AddField(Tags(id, name, partitioning, properties)); + } + + public static ArrayField AddAssets(this ArrayField field, long id, string name, AssetsFieldProperties properties = null) + { + return field.AddField(Assets(id, name, properties)); + } + + public static ArrayField AddBoolean(this ArrayField field, long id, string name, BooleanFieldProperties properties = null) + { + return field.AddField(Boolean(id, name, properties)); + } + + public static ArrayField AddDateTime(this ArrayField field, long id, string name, DateTimeFieldProperties properties = null) + { + return field.AddField(DateTime(id, name, properties)); + } + + public static ArrayField AddGeolocation(this ArrayField field, long id, string name, GeolocationFieldProperties properties = null) + { + return field.AddField(Geolocation(id, name, properties)); + } + + public static ArrayField AddJson(this ArrayField field, long id, string name, JsonFieldProperties properties = null) + { + return field.AddField(Json(id, name, properties)); + } + + public static ArrayField AddNumber(this ArrayField field, long id, string name, NumberFieldProperties properties = null) + { + return field.AddField(Number(id, name, properties)); + } + + public static ArrayField AddReferences(this ArrayField field, long id, string name, ReferencesFieldProperties properties = null) + { + return field.AddField(References(id, name, properties)); + } + + public static ArrayField AddString(this ArrayField field, long id, string name, StringFieldProperties properties = null) + { + return field.AddField(String(id, name, properties)); + } + + public static ArrayField AddTags(this ArrayField field, long id, string name, TagsFieldProperties properties = null) + { + return field.AddField(Tags(id, name, properties)); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/Field{T}.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/Field{T}.cs deleted file mode 100644 index adfb366a1..000000000 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/Field{T}.cs +++ /dev/null @@ -1,61 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Diagnostics.Contracts; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public abstract class Field : Field where T : FieldProperties, new() - { - private T properties; - - public T Properties - { - get { return properties; } - } - - public override FieldProperties RawProperties - { - get { return properties; } - } - - protected Field(long id, string name, Partitioning partitioning, T properties) - : base(id, name, partitioning) - { - Guard.NotNull(properties, nameof(properties)); - - this.properties = properties; - this.properties.Freeze(); - } - - [Pure] - public override Field Update(FieldProperties newProperties) - { - var typedProperties = ValidateProperties(newProperties); - - return Clone>(clone => - { - clone.properties = typedProperties; - clone.properties.Freeze(); - }); - } - - private T ValidateProperties(FieldProperties newProperties) - { - Guard.NotNull(newProperties, nameof(newProperties)); - - if (!(newProperties is T typedProperties)) - { - throw new ArgumentException($"Properties must be of type '{typeof(T)}", nameof(newProperties)); - } - - return typedProperties; - } - } -} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/GeolocationField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/GeolocationField.cs deleted file mode 100644 index 078c6e18f..000000000 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/GeolocationField.cs +++ /dev/null @@ -1,27 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public sealed class GeolocationField : Field - { - public GeolocationField(long id, string name, Partitioning partitioning) - : base(id, name, partitioning, new GeolocationFieldProperties()) - { - } - - public GeolocationField(long id, string name, Partitioning partitioning, GeolocationFieldProperties properties) - : base(id, name, partitioning, properties) - { - } - - public override T Accept(IFieldVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/GeolocationFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/GeolocationFieldProperties.cs index 4cc7b239b..9136b723c 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/GeolocationFieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/GeolocationFieldProperties.cs @@ -9,7 +9,7 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Schemas { - [TypeName(nameof(GeolocationField))] + [TypeName("GeolocationField")] public sealed class GeolocationFieldProperties : FieldProperties { public GeolocationFieldEditor Editor { get; set; } @@ -19,9 +19,19 @@ namespace Squidex.Domain.Apps.Core.Schemas return visitor.Visit(this); } - public override Field CreateField(long id, string name, Partitioning partitioning) + public override T Accept(IFieldVisitor visitor, IField field) { - return new GeolocationField(id, name, partitioning, this); + return visitor.Visit((IField)field); + } + + public override RootField CreateRootField(long id, string name, Partitioning partitioning) + { + return Fields.Geolocation(id, name, partitioning, this); + } + + public override NestedField CreateNestedField(long id, string name) + { + return Fields.Geolocation(id, name, this); } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/IArrayField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/IArrayField.cs new file mode 100644 index 000000000..0ea74cfbd --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/IArrayField.cs @@ -0,0 +1,20 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; + +namespace Squidex.Domain.Apps.Core.Schemas +{ + public interface IArrayField : IField + { + IReadOnlyList Fields { get; } + + IReadOnlyDictionary FieldsById { get; } + + IReadOnlyDictionary FieldsByName { get; } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/IField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/IField.cs new file mode 100644 index 000000000..6cc86239d --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/IField.cs @@ -0,0 +1,26 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Core.Schemas +{ + public interface IField + { + long Id { get; } + + string Name { get; } + + bool IsLocked { get; } + + bool IsDisabled { get; } + + bool IsHidden { get; } + + FieldProperties RawProperties { get; } + + T Accept(IFieldVisitor visitor); + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/IFieldPropertiesVisitor.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/IFieldPropertiesVisitor.cs index 108269359..c4593a450 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/IFieldPropertiesVisitor.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/IFieldPropertiesVisitor.cs @@ -9,6 +9,8 @@ namespace Squidex.Domain.Apps.Core.Schemas { public interface IFieldPropertiesVisitor { + T Visit(ArrayFieldProperties properties); + T Visit(AssetsFieldProperties properties); T Visit(BooleanFieldProperties properties); diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/IFieldVisitor.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/IFieldVisitor.cs index fd4ce2589..67142acc4 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/IFieldVisitor.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/IFieldVisitor.cs @@ -9,22 +9,24 @@ namespace Squidex.Domain.Apps.Core.Schemas { public interface IFieldVisitor { - T Visit(AssetsField field); + T Visit(IArrayField field); - T Visit(BooleanField field); + T Visit(IField field); - T Visit(DateTimeField field); + T Visit(IField field); - T Visit(GeolocationField field); + T Visit(IField field); - T Visit(JsonField field); + T Visit(IField field); - T Visit(NumberField field); + T Visit(IField field); - T Visit(ReferencesField field); + T Visit(IField field); - T Visit(StringField field); + T Visit(IField field); - T Visit(TagsField field); + T Visit(IField field); + + T Visit(IField field); } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/IField{T}.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/IField{T}.cs new file mode 100644 index 000000000..0430e72ec --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/IField{T}.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Core.Schemas +{ + public interface IField : IField + { + T Properties { get; } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/INestedField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/INestedField.cs new file mode 100644 index 000000000..5bacd00eb --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/INestedField.cs @@ -0,0 +1,13 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Core.Schemas +{ + public interface INestedField : IField + { + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/IRootField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/IRootField.cs new file mode 100644 index 000000000..31d9cd05f --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/IRootField.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Core.Schemas +{ + public interface IRootField : IField + { + Partitioning Partitioning { get; } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonFieldModel.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonFieldModel.cs index e4c04fdf8..952ef87f1 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonFieldModel.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonFieldModel.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; using Newtonsoft.Json; namespace Squidex.Domain.Apps.Core.Schemas.Json @@ -15,21 +16,24 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json public long Id { get; set; } [JsonProperty] - public bool IsHidden { get; set; } + public string Name { get; set; } [JsonProperty] - public bool IsLocked { get; set; } + public string Partitioning { get; set; } [JsonProperty] - public bool IsDisabled { get; set; } + public bool IsHidden { get; set; } [JsonProperty] - public string Name { get; set; } + public bool IsLocked { get; set; } [JsonProperty] - public string Partitioning { get; set; } + public bool IsDisabled { get; set; } [JsonProperty] public FieldProperties Properties { get; set; } + + [JsonProperty] + public List Children { get; set; } } } \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonNestedFieldModel.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonNestedFieldModel.cs new file mode 100644 index 000000000..59b2035e7 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonNestedFieldModel.cs @@ -0,0 +1,29 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Newtonsoft.Json; + +namespace Squidex.Domain.Apps.Core.Schemas.Json +{ + public sealed class JsonNestedFieldModel + { + [JsonProperty] + public long Id { get; set; } + + [JsonProperty] + public string Name { get; set; } + + [JsonProperty] + public bool IsHidden { get; set; } + + [JsonProperty] + public bool IsDisabled { get; set; } + + [JsonProperty] + public FieldProperties Properties { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonSchemaModel.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonSchemaModel.cs index be5b64a66..37af1b9e6 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonSchemaModel.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonSchemaModel.cs @@ -13,7 +13,7 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json { public sealed class JsonSchemaModel { - private static readonly Field[] Empty = new Field[0]; + private static readonly RootField[] Empty = new RootField[0]; [JsonProperty] public string Name { get; set; } @@ -38,11 +38,12 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json Properties = schema.Properties; Fields = - schema.Fields?.Select(x => + schema.Fields.Select(x => new JsonFieldModel { Id = x.Id, Name = x.Name, + Children = CreateChildren(x), IsHidden = x.IsHidden, IsLocked = x.IsLocked, IsDisabled = x.IsDisabled, @@ -53,13 +54,31 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json IsPublished = schema.IsPublished; } - public Schema ToSchema(FieldRegistry fieldRegistry) + private static List CreateChildren(IField field) { - Field[] fields = Empty; + if (field is ArrayField arrayField) + { + return arrayField.Fields.Select(x => + new JsonNestedFieldModel + { + Id = x.Id, + Name = x.Name, + IsHidden = x.IsHidden, + IsDisabled = x.IsDisabled, + Properties = x.RawProperties + }).ToList(); + } + + return null; + } + + public Schema ToSchema(FieldRegistry registry) + { + var fields = Empty; if (Fields != null) { - fields = new Field[Fields.Count]; + fields = new RootField[Fields.Count]; for (var i = 0; i < fields.Length; i++) { @@ -67,7 +86,29 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json var parititonKey = new Partitioning(fieldModel.Partitioning); - var field = fieldRegistry.CreateField(fieldModel.Id, fieldModel.Name, parititonKey, fieldModel.Properties); + var field = registry.CreateRootField(fieldModel.Id, fieldModel.Name, parititonKey, fieldModel.Properties); + + if (field is ArrayField arrayField && fieldModel.Children?.Count > 0) + { + foreach (var nestedFieldModel in fieldModel.Children) + { + var nestedField = registry.CreateNestedField(nestedFieldModel.Id, nestedFieldModel.Name, nestedFieldModel.Properties); + + if (nestedFieldModel.IsHidden) + { + nestedField = nestedField.Hide(); + } + + if (nestedFieldModel.IsDisabled) + { + nestedField = nestedField.Disable(); + } + + arrayField = arrayField.AddField(nestedField); + } + + field = arrayField; + } if (fieldModel.IsDisabled) { diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/JsonField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/JsonField.cs deleted file mode 100644 index 9651d6ac0..000000000 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/JsonField.cs +++ /dev/null @@ -1,27 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public sealed class JsonField : Field - { - public JsonField(long id, string name, Partitioning partitioning) - : base(id, name, partitioning, new JsonFieldProperties()) - { - } - - public JsonField(long id, string name, Partitioning partitioning, JsonFieldProperties properties) - : base(id, name, partitioning, properties) - { - } - - public override T Accept(IFieldVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/JsonFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/JsonFieldProperties.cs index 25f4beb18..6edb4f80b 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/JsonFieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/JsonFieldProperties.cs @@ -9,7 +9,7 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Schemas { - [TypeName(nameof(JsonField))] + [TypeName("JsonField")] public sealed class JsonFieldProperties : FieldProperties { public override T Accept(IFieldPropertiesVisitor visitor) @@ -17,9 +17,19 @@ namespace Squidex.Domain.Apps.Core.Schemas return visitor.Visit(this); } - public override Field CreateField(long id, string name, Partitioning partitioning) + public override T Accept(IFieldVisitor visitor, IField field) { - return new JsonField(id, name, partitioning, this); + return visitor.Visit((IField)field); + } + + public override RootField CreateRootField(long id, string name, Partitioning partitioning) + { + return Fields.Json(id, name, partitioning, this); + } + + public override NestedField CreateNestedField(long id, string name) + { + return Fields.Json(id, name, this); } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/NestedField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/NestedField.cs new file mode 100644 index 000000000..c958951ad --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/NestedField.cs @@ -0,0 +1,106 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Diagnostics.Contracts; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.Schemas +{ + public abstract class NestedField : Cloneable, INestedField + { + private readonly long fieldId; + private readonly string fieldName; + private bool isDisabled; + private bool isHidden; + private bool isLocked; + + public long Id + { + get { return fieldId; } + } + + public string Name + { + get { return fieldName; } + } + + public bool IsLocked + { + get { return isLocked; } + } + + public bool IsHidden + { + get { return isHidden; } + } + + public bool IsDisabled + { + get { return isDisabled; } + } + + public abstract FieldProperties RawProperties { get; } + + protected NestedField(long id, string name) + { + Guard.NotNullOrEmpty(name, nameof(name)); + Guard.GreaterThan(id, 0, nameof(id)); + + fieldId = id; + fieldName = name; + } + + [Pure] + public NestedField Lock() + { + return Clone(clone => + { + clone.isLocked = true; + }); + } + + [Pure] + public NestedField Hide() + { + return Clone(clone => + { + clone.isHidden = true; + }); + } + + [Pure] + public NestedField Show() + { + return Clone(clone => + { + clone.isHidden = false; + }); + } + + [Pure] + public NestedField Disable() + { + return Clone(clone => + { + clone.isDisabled = true; + }); + } + + [Pure] + public NestedField Enable() + { + return Clone(clone => + { + clone.isDisabled = false; + }); + } + + public abstract T Accept(IFieldVisitor visitor); + + public abstract NestedField Update(FieldProperties newProperties); + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/NestedField{T}.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/NestedField{T}.cs new file mode 100644 index 000000000..7de914a4b --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/NestedField{T}.cs @@ -0,0 +1,70 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Diagnostics.Contracts; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.Schemas +{ + public class NestedField : NestedField, IField where T : FieldProperties, new() + { + private T properties; + + public T Properties + { + get { return properties; } + } + + public override FieldProperties RawProperties + { + get { return properties; } + } + + public NestedField(long id, string name, T properties) + : base(id, name) + { + Guard.NotNull(properties, nameof(properties)); + + SetProperties(properties); + } + + [Pure] + public override NestedField Update(FieldProperties newProperties) + { + var typedProperties = ValidateProperties(newProperties); + + return Clone>(clone => + { + clone.SetProperties(typedProperties); + }); + } + + private void SetProperties(T newProperties) + { + properties = newProperties; + properties.Freeze(); + } + + private T ValidateProperties(FieldProperties newProperties) + { + Guard.NotNull(newProperties, nameof(newProperties)); + + if (!(newProperties is T typedProperties)) + { + throw new ArgumentException($"Properties must be of type '{typeof(T)}", nameof(newProperties)); + } + + return typedProperties; + } + + public override TResult Accept(IFieldVisitor visitor) + { + return properties.Accept(visitor, this); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/NumberField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/NumberField.cs deleted file mode 100644 index 791698441..000000000 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/NumberField.cs +++ /dev/null @@ -1,27 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public sealed class NumberField : Field - { - public NumberField(long id, string name, Partitioning partitioning) - : base(id, name, partitioning, new NumberFieldProperties()) - { - } - - public NumberField(long id, string name, Partitioning partitioning, NumberFieldProperties properties) - : base(id, name, partitioning, properties) - { - } - - public override T Accept(IFieldVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/NumberFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/NumberFieldProperties.cs index 5732601a9..3238aff25 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/NumberFieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/NumberFieldProperties.cs @@ -10,7 +10,7 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Schemas { - [TypeName(nameof(NumberField))] + [TypeName("NumberField")] public sealed class NumberFieldProperties : FieldProperties { public ImmutableList AllowedValues { get; set; } @@ -30,9 +30,19 @@ namespace Squidex.Domain.Apps.Core.Schemas return visitor.Visit(this); } - public override Field CreateField(long id, string name, Partitioning partitioning) + public override T Accept(IFieldVisitor visitor, IField field) { - return new NumberField(id, name, partitioning, this); + return visitor.Visit((IField)field); + } + + public override RootField CreateRootField(long id, string name, Partitioning partitioning) + { + return Fields.Number(id, name, partitioning, this); + } + + public override NestedField CreateNestedField(long id, string name) + { + return Fields.Number(id, name, this); } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesField.cs deleted file mode 100644 index 51ba26839..000000000 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesField.cs +++ /dev/null @@ -1,27 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public sealed class ReferencesField : Field - { - public ReferencesField(long id, string name, Partitioning partitioning) - : base(id, name, partitioning, new ReferencesFieldProperties()) - { - } - - public ReferencesField(long id, string name, Partitioning partitioning, ReferencesFieldProperties properties) - : base(id, name, partitioning, properties) - { - } - - public override T Accept(IFieldVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesFieldProperties.cs index cc3740bda..98e4bb5ec 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesFieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesFieldProperties.cs @@ -10,7 +10,7 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Schemas { - [TypeName(nameof(ReferencesField))] + [TypeName("ReferencesField")] public sealed class ReferencesFieldProperties : FieldProperties { public int? MinItems { get; set; } @@ -24,9 +24,19 @@ namespace Squidex.Domain.Apps.Core.Schemas return visitor.Visit(this); } - public override Field CreateField(long id, string name, Partitioning partitioning) + public override T Accept(IFieldVisitor visitor, IField field) { - return new ReferencesField(id, name, partitioning, this); + return visitor.Visit((IField)field); + } + + public override RootField CreateRootField(long id, string name, Partitioning partitioning) + { + return Fields.References(id, name, partitioning, this); + } + + public override NestedField CreateNestedField(long id, string name) + { + return Fields.References(id, name, this); } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/RootField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/RootField.cs new file mode 100644 index 000000000..461f60365 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/RootField.cs @@ -0,0 +1,115 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Diagnostics.Contracts; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.Schemas +{ + public abstract class RootField : Cloneable, IRootField + { + private readonly long fieldId; + private readonly string fieldName; + private readonly Partitioning partitioning; + private bool isDisabled; + private bool isHidden; + private bool isLocked; + + public long Id + { + get { return fieldId; } + } + + public string Name + { + get { return fieldName; } + } + + public bool IsLocked + { + get { return isLocked; } + } + + public bool IsHidden + { + get { return isHidden; } + } + + public bool IsDisabled + { + get { return isDisabled; } + } + + public Partitioning Partitioning + { + get { return partitioning; } + } + + public abstract FieldProperties RawProperties { get; } + + protected RootField(long id, string name, Partitioning partitioning) + { + Guard.NotNullOrEmpty(name, nameof(name)); + Guard.GreaterThan(id, 0, nameof(id)); + Guard.NotNull(partitioning, nameof(partitioning)); + + fieldId = id; + fieldName = name; + + this.partitioning = partitioning; + } + + [Pure] + public RootField Lock() + { + return Clone(clone => + { + clone.isLocked = true; + }); + } + + [Pure] + public RootField Hide() + { + return Clone(clone => + { + clone.isHidden = true; + }); + } + + [Pure] + public RootField Show() + { + return Clone(clone => + { + clone.isHidden = false; + }); + } + + [Pure] + public RootField Disable() + { + return Clone(clone => + { + clone.isDisabled = true; + }); + } + + [Pure] + public RootField Enable() + { + return Clone(clone => + { + clone.isDisabled = false; + }); + } + + public abstract T Accept(IFieldVisitor visitor); + + public abstract RootField Update(FieldProperties newProperties); + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/RootField{T}.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/RootField{T}.cs new file mode 100644 index 000000000..90165643b --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/RootField{T}.cs @@ -0,0 +1,70 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Diagnostics.Contracts; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.Schemas +{ + public class RootField : RootField, IField where T : FieldProperties, new() + { + private T properties; + + public T Properties + { + get { return properties; } + } + + public override FieldProperties RawProperties + { + get { return properties; } + } + + public RootField(long id, string name, Partitioning partitioning, T properties) + : base(id, name, partitioning) + { + Guard.NotNull(properties, nameof(properties)); + + SetProperties(properties); + } + + [Pure] + public override RootField Update(FieldProperties newProperties) + { + var typedProperties = ValidateProperties(newProperties); + + return Clone>(clone => + { + clone.SetProperties(typedProperties); + }); + } + + private void SetProperties(T newProperties) + { + properties = newProperties; + properties.Freeze(); + } + + private T ValidateProperties(FieldProperties newProperties) + { + Guard.NotNull(newProperties, nameof(newProperties)); + + if (!(newProperties is T typedProperties)) + { + throw new ArgumentException($"Properties must be of type '{typeof(T)}", nameof(newProperties)); + } + + return typedProperties; + } + + public override TResult Accept(IFieldVisitor visitor) + { + return properties.Accept(visitor, this); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/Schema.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/Schema.cs index ec20d7e8a..c6e187087 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/Schema.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/Schema.cs @@ -7,9 +7,7 @@ using System; using System.Collections.Generic; -using System.Collections.Immutable; using System.Diagnostics.Contracts; -using System.Linq; using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Schemas @@ -17,9 +15,7 @@ namespace Squidex.Domain.Apps.Core.Schemas public sealed class Schema : Cloneable { private readonly string name; - private ImmutableArray fieldsOrdered = ImmutableArray.Empty; - private ImmutableDictionary fieldsById; - private ImmutableDictionary fieldsByName; + private FieldCollection fields = FieldCollection.Empty; private SchemaProperties properties; private bool isPublished; @@ -33,49 +29,19 @@ namespace Squidex.Domain.Apps.Core.Schemas get { return isPublished; } } - public IReadOnlyList Fields + public IReadOnlyList Fields { - get { return fieldsOrdered; } + get { return fields.Ordered; } } - public IReadOnlyDictionary FieldsById + public IReadOnlyDictionary FieldsById { - get - { - if (fieldsById == null) - { - if (fieldsOrdered.Length == 0) - { - fieldsById = ImmutableDictionary.Empty; - } - else - { - fieldsById = fieldsOrdered.ToImmutableDictionary(x => x.Id); - } - } - - return fieldsById; - } + get { return fields.ById; } } - public IReadOnlyDictionary FieldsByName + public IReadOnlyDictionary FieldsByName { - get - { - if (fieldsByName == null) - { - if (fieldsOrdered.Length == 0) - { - fieldsByName = ImmutableDictionary.Empty; - } - else - { - fieldsByName = fieldsOrdered.ToImmutableDictionary(x => x.Name); - } - } - - return fieldsByName; - } + get { return fields.ByName; } } public SchemaProperties Properties @@ -93,21 +59,14 @@ namespace Squidex.Domain.Apps.Core.Schemas this.properties.Freeze(); } - public Schema(string name, Field[] fields, SchemaProperties properties, bool isPublished) + public Schema(string name, RootField[] fields, SchemaProperties properties, bool isPublished) : this(name, properties) { - Guard.NotNullOrEmpty(name, nameof(name)); Guard.NotNull(fields, nameof(fields)); - this.isPublished = isPublished; - - fieldsOrdered = ImmutableArray.Create(fields); - } + this.fields = new FieldCollection(fields); - protected override void OnCloned() - { - fieldsById = null; - fieldsByName = null; + this.isPublished = isPublished; } [Pure] @@ -122,60 +81,6 @@ namespace Squidex.Domain.Apps.Core.Schemas }); } - [Pure] - public Schema UpdateField(long fieldId, FieldProperties newProperties) - { - return UpdateField(fieldId, field => - { - return field.Update(newProperties); - }); - } - - [Pure] - public Schema LockField(long fieldId) - { - return UpdateField(fieldId, field => - { - return field.Lock(); - }); - } - - [Pure] - public Schema DisableField(long fieldId) - { - return UpdateField(fieldId, field => - { - return field.Disable(); - }); - } - - [Pure] - public Schema EnableField(long fieldId) - { - return UpdateField(fieldId, field => - { - return field.Enable(); - }); - } - - [Pure] - public Schema HideField(long fieldId) - { - return UpdateField(fieldId, field => - { - return field.Hide(); - }); - } - - [Pure] - public Schema ShowField(long fieldId) - { - return UpdateField(fieldId, field => - { - return field.Show(); - }); - } - [Pure] public Schema Publish() { @@ -197,62 +102,39 @@ namespace Squidex.Domain.Apps.Core.Schemas [Pure] public Schema DeleteField(long fieldId) { - if (!FieldsById.TryGetValue(fieldId, out var field)) - { - return this; - } - - return Clone(clone => - { - clone.fieldsOrdered = fieldsOrdered.Remove(field); - }); + return Updatefields(f => f.Remove(fieldId)); } [Pure] public Schema ReorderFields(List ids) { - Guard.NotNull(ids, nameof(ids)); - - if (ids.Count != fieldsOrdered.Length || ids.Any(x => !FieldsById.ContainsKey(x))) - { - throw new ArgumentException("Ids must cover all fields.", nameof(ids)); - } - - return Clone(clone => - { - clone.fieldsOrdered = fieldsOrdered.OrderBy(f => ids.IndexOf(f.Id)).ToImmutableArray(); - }); + return Updatefields(f => f.Reorder(ids)); } [Pure] - public Schema AddField(Field field) + public Schema AddField(RootField field) { - Guard.NotNull(field, nameof(field)); - - if (FieldsByName.ContainsKey(field.Name) || FieldsById.ContainsKey(field.Id)) - { - throw new ArgumentException($"A field with name '{field.Name}' and id {field.Id} already exists.", nameof(field)); - } - - return Clone(clone => - { - clone.fieldsOrdered = clone.fieldsOrdered.Add(field); - }); + return Updatefields(f => f.Add(field)); } [Pure] - public Schema UpdateField(long fieldId, Func updater) + public Schema UpdateField(long fieldId, Func updater) + { + return Updatefields(f => f.Update(fieldId, updater)); + } + + private Schema Updatefields(Func, FieldCollection> updater) { - Guard.NotNull(updater, nameof(updater)); + var newFields = updater(fields); - if (!FieldsById.TryGetValue(fieldId, out var field)) + if (ReferenceEquals(newFields, fields)) { return this; } return Clone(clone => { - clone.fieldsOrdered = clone.fieldsOrdered.Replace(field, updater(field)); + clone.fields = newFields; }); } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/StringField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/StringField.cs deleted file mode 100644 index 533d88601..000000000 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/StringField.cs +++ /dev/null @@ -1,27 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public sealed class StringField : Field - { - public StringField(long id, string name, Partitioning partitioning) - : base(id, name, partitioning, new StringFieldProperties()) - { - } - - public StringField(long id, string name, Partitioning partitioning, StringFieldProperties properties) - : base(id, name, partitioning, properties) - { - } - - public override T Accept(IFieldVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldEditor.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldEditor.cs index c7130e1de..ad5aec8cc 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldEditor.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldEditor.cs @@ -10,6 +10,7 @@ namespace Squidex.Domain.Apps.Core.Schemas public enum StringFieldEditor { Input, + Color, Markdown, Dropdown, Radio, diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldProperties.cs index df46bdf6d..e9731480d 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldProperties.cs @@ -10,7 +10,7 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Schemas { - [TypeName(nameof(StringField))] + [TypeName("StringField")] public sealed class StringFieldProperties : FieldProperties { public ImmutableList AllowedValues { get; set; } @@ -34,9 +34,19 @@ namespace Squidex.Domain.Apps.Core.Schemas return visitor.Visit(this); } - public override Field CreateField(long id, string name, Partitioning partitioning) + public override T Accept(IFieldVisitor visitor, IField field) { - return new StringField(id, name, partitioning, this); + return visitor.Visit((IField)field); + } + + public override RootField CreateRootField(long id, string name, Partitioning partitioning) + { + return Fields.String(id, name, partitioning, this); + } + + public override NestedField CreateNestedField(long id, string name) + { + return Fields.String(id, name, this); } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/TagsField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/TagsField.cs deleted file mode 100644 index 5a792760f..000000000 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/TagsField.cs +++ /dev/null @@ -1,27 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public sealed class TagsField : Field - { - public TagsField(long id, string name, Partitioning partitioning) - : base(id, name, partitioning, new TagsFieldProperties()) - { - } - - public TagsField(long id, string name, Partitioning partitioning, TagsFieldProperties properties) - : base(id, name, partitioning, properties) - { - } - - public override T Accept(IFieldVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/TagsFieldNormalization.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/TagsFieldNormalization.cs new file mode 100644 index 000000000..6dd40eb26 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/TagsFieldNormalization.cs @@ -0,0 +1,15 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Core.Schemas +{ + public enum TagsFieldNormalization + { + None, + Schema + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/TagsFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/TagsFieldProperties.cs index a87e2fd44..0c2b5df37 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/TagsFieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/TagsFieldProperties.cs @@ -9,21 +9,33 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Schemas { - [TypeName(nameof(TagsField))] + [TypeName("TagsField")] public sealed class TagsFieldProperties : FieldProperties { public int? MinItems { get; set; } public int? MaxItems { get; set; } + public TagsFieldNormalization Normalization { get; set; } + public override T Accept(IFieldPropertiesVisitor visitor) { return visitor.Visit(this); } - public override Field CreateField(long id, string name, Partitioning partitioning) + public override T Accept(IFieldVisitor visitor, IField field) + { + return visitor.Visit((IField)field); + } + + public override RootField CreateRootField(long id, string name, Partitioning partitioning) + { + return Fields.Tags(id, name, partitioning, this); + } + + public override NestedField CreateNestedField(long id, string name) { - return new TagsField(id, name, partitioning, this); + return Fields.Tags(id, name, this); } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj b/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj index 91ab5c926..bd47258f1 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj +++ b/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj @@ -1,4 +1,4 @@ - + netstandard2.0 Squidex.Domain.Apps.Core @@ -8,10 +8,14 @@ True - - - - + + all + runtime; build; native; contentfiles; analyzers + + + + + diff --git a/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/ContentConverter.cs b/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/ContentConverter.cs index 5bc9a1834..52284631d 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/ContentConverter.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/ContentConverter.cs @@ -7,195 +7,90 @@ using System; using System.Collections.Generic; -using System.Linq; -using System.Text; -using Newtonsoft.Json.Linq; -using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Infrastructure; -using Squidex.Infrastructure.Json; namespace Squidex.Domain.Apps.Core.ConvertContent { public static class ContentConverter { - public static NamedContentData ToNameModel(this IdContentData source, Schema schema, bool decodeJsonField) + private static readonly Func KeyNameResolver = f => f.Name; + private static readonly Func KeyIdResolver = f => f.Id; + + public static NamedContentData ConvertId2Name(this IdContentData content, Schema schema, params FieldConverter[] converters) { Guard.NotNull(schema, nameof(schema)); - var result = new NamedContentData(); - - foreach (var fieldValue in source) - { - if (!schema.FieldsById.TryGetValue(fieldValue.Key, out var field)) - { - continue; - } - - if (decodeJsonField && field is JsonField) - { - var encodedValue = new ContentFieldData(); - - foreach (var partitionValue in fieldValue.Value) - { - if (partitionValue.Value.IsNull()) - { - encodedValue[partitionValue.Key] = null; - } - else - { - var value = Encoding.UTF8.GetString(Convert.FromBase64String(partitionValue.Value.ToString())); - - encodedValue[partitionValue.Key] = JToken.Parse(value); - } - } - - result[field.Name] = encodedValue; - } - else - { - result[field.Name] = fieldValue.Value; - } - } + var result = new NamedContentData(content.Count); - return result; + return ConvertInternal(content, result, schema.FieldsById, KeyNameResolver, converters); } - public static IdContentData ToIdModel(this NamedContentData content, Schema schema, bool encodeJsonField) + public static IdContentData ConvertId2Id(this IdContentData content, Schema schema, params FieldConverter[] converters) { Guard.NotNull(schema, nameof(schema)); - var result = new IdContentData(); - - foreach (var fieldValue in content) - { - if (!schema.FieldsByName.TryGetValue(fieldValue.Key, out var field)) - { - continue; - } - - var fieldId = field.Id; + var result = new IdContentData(content.Count); - if (encodeJsonField && field is JsonField) - { - var encodedValue = new ContentFieldData(); - - foreach (var partitionValue in fieldValue.Value) - { - if (partitionValue.Value.IsNull()) - { - encodedValue[partitionValue.Key] = null; - } - else - { - var value = Convert.ToBase64String(Encoding.UTF8.GetBytes(partitionValue.Value.ToString())); + return ConvertInternal(content, result, schema.FieldsById, KeyIdResolver, converters); + } - encodedValue[partitionValue.Key] = value; - } - } + public static NamedContentData ConvertName2Name(this NamedContentData content, Schema schema, params FieldConverter[] converters) + { + Guard.NotNull(schema, nameof(schema)); - result[fieldId] = encodedValue; - } - else - { - result[fieldId] = fieldValue.Value; - } - } + var result = new NamedContentData(content.Count); - return result; + return ConvertInternal(content, result, schema.FieldsByName, KeyNameResolver, converters); } - public static NamedContentData ToApiModel(this NamedContentData content, Schema schema, LanguagesConfig languagesConfig, bool excludeHidden = true) + public static IdContentData ConvertName2Id(this NamedContentData content, Schema schema, params FieldConverter[] converters) { Guard.NotNull(schema, nameof(schema)); - Guard.NotNull(languagesConfig, nameof(languagesConfig)); - var codeForInvariant = InvariantPartitioning.Instance.Master.Key; - var codeForMasterLanguage = languagesConfig.Master.Language.Iso2Code; + var result = new IdContentData(content.Count); - var result = new NamedContentData(); + return ConvertInternal(content, result, schema.FieldsByName, KeyIdResolver, converters); + } - foreach (var fieldValue in content) + private static TDict2 ConvertInternal( + TDict1 source, + TDict2 target, + IReadOnlyDictionary fields, + Func targetKey, params FieldConverter[] converters) + where TDict1 : IDictionary + where TDict2 : IDictionary + { + foreach (var fieldKvp in source) { - if (!schema.FieldsByName.TryGetValue(fieldValue.Key, out var field) || (excludeHidden && field.IsHidden)) + if (!fields.TryGetValue(fieldKvp.Key, out var field)) { continue; } - var fieldResult = new ContentFieldData(); - var fieldValues = fieldValue.Value; + var newvalue = fieldKvp.Value; - if (field.Partitioning.Equals(Partitioning.Language)) + if (converters != null) { - foreach (var languageConfig in languagesConfig) + foreach (var converter in converters) { - var languageCode = languageConfig.Key; + newvalue = converter(newvalue, field); - if (fieldValues.TryGetValue(languageCode, out var value)) - { - fieldResult.Add(languageCode, value); - } - else if (languageConfig == languagesConfig.Master && fieldValues.TryGetValue(codeForInvariant, out value)) + if (newvalue == null) { - fieldResult.Add(languageCode, value); + break; } } } - else - { - if (fieldValues.TryGetValue(codeForInvariant, out var value)) - { - fieldResult.Add(codeForInvariant, value); - } - else if (fieldValues.TryGetValue(codeForMasterLanguage, out value)) - { - fieldResult.Add(codeForInvariant, value); - } - else if (fieldValues.Count > 0) - { - fieldResult.Add(codeForInvariant, fieldValues.Values.First()); - } - } - result.Add(field.Name, fieldResult); - } - - return result; - } - - public static object ToLanguageModel(this NamedContentData content, LanguagesConfig languagesConfig, IReadOnlyCollection languagePreferences = null) - { - Guard.NotNull(languagesConfig, nameof(languagesConfig)); - - if (languagePreferences == null || languagePreferences.Count == 0) - { - return content; - } - - if (languagePreferences.Count == 1 && languagesConfig.TryGetConfig(languagePreferences.First(), out var languageConfig)) - { - languagePreferences = languagePreferences.Union(languageConfig.LanguageFallbacks).ToList(); - } - - var result = new Dictionary(); - - foreach (var fieldValue in content) - { - var fieldValues = fieldValue.Value; - - foreach (var language in languagePreferences) + if (newvalue != null) { - if (fieldValues.TryGetValue(language, out var value) && value != null) - { - result[fieldValue.Key] = value; - - break; - } + target.Add(targetKey(field), newvalue); } } - return result; + return target; } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/ContentConverterFlat.cs b/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/ContentConverterFlat.cs new file mode 100644 index 000000000..5bff83392 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/ContentConverterFlat.cs @@ -0,0 +1,74 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.ConvertContent +{ + public static class ContentConverterFlat + { + public static object ToFlatLanguageModel(this NamedContentData content, LanguagesConfig languagesConfig, IReadOnlyCollection languagePreferences = null) + { + Guard.NotNull(languagesConfig, nameof(languagesConfig)); + + if (languagePreferences == null || languagePreferences.Count == 0) + { + return content; + } + + if (languagePreferences.Count == 1 && languagesConfig.TryGetConfig(languagePreferences.First(), out var languageConfig)) + { + languagePreferences = languagePreferences.Union(languageConfig.LanguageFallbacks).ToList(); + } + + var result = new Dictionary(); + + foreach (var fieldValue in content) + { + var fieldData = fieldValue.Value; + + foreach (var language in languagePreferences) + { + if (fieldData.TryGetValue(language, out var value) && value != null) + { + result[fieldValue.Key] = value; + + break; + } + } + } + + return result; + } + + public static Dictionary ToFlatten(this NamedContentData content) + { + var result = new Dictionary(); + + foreach (var fieldValue in content) + { + var fieldData = fieldValue.Value; + + if (fieldData.Count == 1) + { + result[fieldValue.Key] = fieldData.Values.First(); + } + else + { + result[fieldValue.Key] = fieldData; + } + } + + return result; + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/FieldConverters.cs b/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/FieldConverters.cs new file mode 100644 index 000000000..1f373f154 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/FieldConverters.cs @@ -0,0 +1,343 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Core.ValidateContent; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Json; + +#pragma warning disable RECS0002 // Convert anonymous method to method group + +namespace Squidex.Domain.Apps.Core.ConvertContent +{ + public delegate ContentFieldData FieldConverter(ContentFieldData data, IRootField field); + + public static class FieldConverters + { + private static readonly Func KeyNameResolver = f => f.Name; + private static readonly Func KeyIdResolver = f => f.Id.ToString(); + + private static readonly Func FieldByIdResolver = + (f, k) => long.TryParse(k, out var id) ? f.FieldsById.GetOrDefault(id) : null; + + private static readonly Func FieldByNameResolver = + (f, k) => f.FieldsByName.GetOrDefault(k); + + public static FieldConverter ExcludeHidden() + { + return (data, field) => field.IsHidden ? null : data; + } + + public static FieldConverter ExcludeChangedTypes() + { + return (data, field) => + { + foreach (var value in data.Values) + { + if (value.IsNull()) + { + continue; + } + + try + { + JsonValueConverter.ConvertValue(field, value); + } + catch + { + return null; + } + } + + return data; + }; + } + + public static FieldConverter ResolveInvariant(LanguagesConfig config) + { + var codeForInvariant = InvariantPartitioning.Instance.Master.Key; + var codeForMasterLanguage = config.Master.Language.Iso2Code; + + return (data, field) => + { + if (field.Partitioning.Equals(Partitioning.Invariant)) + { + var result = new ContentFieldData(); + + if (data.TryGetValue(codeForInvariant, out var value)) + { + result[codeForInvariant] = value; + } + else if (data.TryGetValue(codeForMasterLanguage, out value)) + { + result[codeForInvariant] = value; + } + else if (data.Count > 0) + { + result[codeForInvariant] = data.Values.First(); + } + + return result; + } + + return data; + }; + } + + public static FieldConverter ResolveLanguages(LanguagesConfig config) + { + var codeForInvariant = InvariantPartitioning.Instance.Master.Key; + + return (data, field) => + { + if (field.Partitioning.Equals(Partitioning.Language)) + { + var result = new ContentFieldData(); + + foreach (var languageConfig in config) + { + var languageCode = languageConfig.Key; + + if (data.TryGetValue(languageCode, out var value)) + { + result[languageCode] = value; + } + else if (languageConfig == config.Master && data.TryGetValue(codeForInvariant, out value)) + { + result[languageCode] = value; + } + } + + return result; + } + + return data; + }; + } + + public static FieldConverter ResolveFallbackLanguages(LanguagesConfig config) + { + var master = config.Master; + + return (data, field) => + { + if (field.Partitioning.Equals(Partitioning.Language)) + { + foreach (var languageConfig in config) + { + var languageCode = languageConfig.Key; + + if (!data.TryGetValue(languageCode, out var value)) + { + var dataFound = false; + + foreach (var fallback in languageConfig.Fallback) + { + if (data.TryGetValue(fallback, out value)) + { + data[languageCode] = value; + dataFound = true; + break; + } + } + + if (!dataFound && languageConfig != master) + { + if (data.TryGetValue(master.Language, out value)) + { + data[languageCode] = value; + } + } + } + } + } + + return data; + }; + } + + public static FieldConverter FilterLanguages(LanguagesConfig config, IEnumerable languages) + { + if (languages?.Any() != true) + { + return (data, field) => data; + } + + var languageSet = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var language in languages) + { + if (config.Contains(language.Iso2Code)) + { + languageSet.Add(language.Iso2Code); + } + } + + if (languageSet.Count == 0) + { + languageSet.Add(config.Master.Language.Iso2Code); + } + + return (data, field) => + { + if (field.Partitioning.Equals(Partitioning.Language)) + { + var result = new ContentFieldData(); + + foreach (var languageCode in languageSet) + { + if (data.TryGetValue(languageCode, out var value)) + { + result[languageCode] = value; + } + } + + return result; + } + + return data; + }; + } + + public static FieldConverter ForNestedName2Name(params ValueConverter[] converters) + { + return ForNested(FieldByNameResolver, KeyNameResolver, converters); + } + + public static FieldConverter ForNestedName2Id(params ValueConverter[] converters) + { + return ForNested(FieldByNameResolver, KeyIdResolver, converters); + } + + public static FieldConverter ForNestedId2Name(params ValueConverter[] converters) + { + return ForNested(FieldByIdResolver, KeyNameResolver, converters); + } + + public static FieldConverter ForNestedId2Id(params ValueConverter[] converters) + { + return ForNested(FieldByIdResolver, KeyIdResolver, converters); + } + + private static FieldConverter ForNested( + Func fieldResolver, + Func keyResolver, + params ValueConverter[] converters) + { + return (data, field) => + { + if (field is IArrayField arrayField) + { + var result = new ContentFieldData(); + + foreach (var partition in data) + { + if (!(partition.Value is JArray jArray)) + { + continue; + } + + var newArray = new JArray(); + + foreach (var item in jArray.OfType()) + { + var newItem = new JObject(); + + foreach (var kvp in item) + { + var nestedField = fieldResolver(arrayField, kvp.Key); + + if (nestedField == null) + { + continue; + } + + var newValue = kvp.Value; + + var isUnset = false; + + if (converters != null) + { + foreach (var converter in converters) + { + newValue = converter(newValue, nestedField); + + if (ReferenceEquals(newValue, Value.Unset)) + { + isUnset = true; + break; + } + } + } + + if (!isUnset) + { + newItem.Add(keyResolver(nestedField), newValue); + } + } + + newArray.Add(newItem); + } + + result.Add(partition.Key, newArray); + } + + return result; + } + + return data; + }; + } + + public static FieldConverter ForValues(params ValueConverter[] converters) + { + return (data, field) => + { + if (!(field is IArrayField)) + { + var result = new ContentFieldData(); + + foreach (var partition in data) + { + var newValue = partition.Value; + + var isUnset = false; + + if (converters != null) + { + foreach (var converter in converters) + { + newValue = converter(newValue, field); + + if (ReferenceEquals(newValue, Value.Unset)) + { + isUnset = true; + break; + } + } + } + + if (!isUnset) + { + result.Add(partition.Key, newValue); + } + } + + return result; + } + + return data; + }; + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/Value.cs b/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/Value.cs new file mode 100644 index 000000000..2229afdfa --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/Value.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Newtonsoft.Json.Linq; + +namespace Squidex.Domain.Apps.Core.ConvertContent +{ + public static class Value + { + public static readonly JToken Unset = JValue.CreateUndefined(); + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/ValueConverters.cs b/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/ValueConverters.cs new file mode 100644 index 000000000..343077c38 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/ValueConverters.cs @@ -0,0 +1,78 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Text; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Core.ValidateContent; +using Squidex.Infrastructure.Json; + +namespace Squidex.Domain.Apps.Core.ConvertContent +{ + public delegate JToken ValueConverter(JToken value, IField field); + + public static class ValueConverters + { + public static ValueConverter DecodeJson() + { + return (value, field) => + { + if (!value.IsNull() && field is IField) + { + var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(value.ToString())); + + return JToken.Parse(decoded); + } + + return value; + }; + } + + public static ValueConverter EncodeJson() + { + return (value, field) => + { + if (!value.IsNull() && field is IField) + { + var encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(value.ToString())); + + return encoded; + } + + return value; + }; + } + + public static ValueConverter ExcludeHidden() + { + return (value, field) => field.IsHidden ? Value.Unset : value; + } + + public static ValueConverter ExcludeChangedTypes() + { + return (value, field) => + { + if (value.IsNull()) + { + return value; + } + + try + { + JsonValueConverter.ConvertValue(field, value); + } + catch + { + return Value.Unset; + } + + return value; + }; + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/EnrichContent/ContentEnricher.cs b/src/Squidex.Domain.Apps.Core.Operations/EnrichContent/ContentEnricher.cs index 540e5f43d..2460a2c00 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/EnrichContent/ContentEnricher.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/EnrichContent/ContentEnricher.cs @@ -50,7 +50,7 @@ namespace Squidex.Domain.Apps.Core.EnrichContent } } - private static void Enrich(Field field, ContentFieldData fieldData, IFieldPartitionItem partitionItem) + private static void Enrich(IField field, ContentFieldData fieldData, IFieldPartitionItem partitionItem) { Guard.NotNull(fieldData, nameof(fieldData)); @@ -69,9 +69,9 @@ namespace Squidex.Domain.Apps.Core.EnrichContent } } - private static bool ShouldApplyDefaultValue(Field field, JToken value) + private static bool ShouldApplyDefaultValue(IField field, JToken value) { - return value.IsNull() || (field is StringField && value is JValue jValue && Equals(jValue.Value, string.Empty)); + return value.IsNull() || (field is IField && value is JValue jValue && Equals(jValue.Value, string.Empty)); } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/EnrichContent/DefaultValueFactory.cs b/src/Squidex.Domain.Apps.Core.Operations/EnrichContent/DefaultValueFactory.cs index 879473fbc..a4f8be960 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/EnrichContent/DefaultValueFactory.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/EnrichContent/DefaultValueFactory.cs @@ -13,7 +13,7 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.EnrichContent { - public sealed class DefaultValueFactory : IFieldPropertiesVisitor + public sealed class DefaultValueFactory : IFieldVisitor { private readonly Instant now; @@ -22,66 +22,71 @@ namespace Squidex.Domain.Apps.Core.EnrichContent this.now = now; } - public static JToken CreateDefaultValue(Field field, Instant now) + public static JToken CreateDefaultValue(IField field, Instant now) { Guard.NotNull(field, nameof(field)); - return field.RawProperties.Accept(new DefaultValueFactory(now)); + return field.Accept(new DefaultValueFactory(now)); } - public JToken Visit(AssetsFieldProperties properties) + public JToken Visit(IArrayField field) { return new JArray(); } - public JToken Visit(BooleanFieldProperties properties) + public JToken Visit(IField field) { - return properties.DefaultValue; + return new JArray(); + } + + public JToken Visit(IField field) + { + return field.Properties.DefaultValue; } - public JToken Visit(GeolocationFieldProperties properties) + public JToken Visit(IField field) { return JValue.CreateNull(); } - public JToken Visit(JsonFieldProperties properties) + public JToken Visit(IField field) { return JValue.CreateNull(); } - public JToken Visit(NumberFieldProperties properties) + public JToken Visit(IField field) { - return properties.DefaultValue; + return field.Properties.DefaultValue; } - public JToken Visit(ReferencesFieldProperties properties) + public JToken Visit(IField field) { return new JArray(); } - public JToken Visit(StringFieldProperties properties) + public JToken Visit(IField field) { - return properties.DefaultValue; + return field.Properties.DefaultValue; } - public JToken Visit(TagsFieldProperties properties) + public JToken Visit(IField field) { return new JArray(); } - public JToken Visit(DateTimeFieldProperties properties) + public JToken Visit(IField field) { - if (properties.CalculatedDefaultValue == DateTimeCalculatedDefaultValue.Now) + if (field.Properties.CalculatedDefaultValue == DateTimeCalculatedDefaultValue.Now) { return now.ToString(); } - if (properties.CalculatedDefaultValue == DateTimeCalculatedDefaultValue.Today) + if (field.Properties.CalculatedDefaultValue == DateTimeCalculatedDefaultValue.Today) { return now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); } - return properties.DefaultValue?.ToString(); + return field.Properties.DefaultValue?.ToString(); } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ContentReferencesExtensions.cs b/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ContentReferencesExtensions.cs index 8eb673ae6..9d4fddf8d 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ContentReferencesExtensions.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ContentReferencesExtensions.cs @@ -17,33 +17,6 @@ namespace Squidex.Domain.Apps.Core.ExtractReferenceIds { public static class ContentReferencesExtensions { - public static IdContentData ToCleanedReferences(this IdContentData source, Schema schema, ISet deletedReferencedIds) - { - Guard.NotNull(schema, nameof(schema)); - Guard.NotNull(deletedReferencedIds, nameof(deletedReferencedIds)); - - var result = new IdContentData(source); - - foreach (var field in schema.Fields) - { - var fieldData = source.GetOrDefault(field.Id); - - if (fieldData == null) - { - continue; - } - - foreach (var partitionValue in fieldData.Where(x => !x.Value.IsNull()).ToList()) - { - var newValue = field.CleanReferences(partitionValue.Value, deletedReferencedIds); - - fieldData[partitionValue.Key] = newValue; - } - } - - return result; - } - public static IEnumerable GetReferencedIds(this IdContentData source, Schema schema) { Guard.NotNull(schema, nameof(schema)); diff --git a/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesCleaner.cs b/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesCleaner.cs index 834bc6a87..a817aba20 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesCleaner.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesCleaner.cs @@ -7,64 +7,95 @@ using System; using System.Collections.Generic; -using System.Linq; using Newtonsoft.Json.Linq; using Squidex.Domain.Apps.Core.Schemas; -using Squidex.Infrastructure.Json; namespace Squidex.Domain.Apps.Core.ExtractReferenceIds { - public static class ReferencesCleaner + public sealed class ReferencesCleaner : IFieldVisitor { - private static readonly List EmptyIds = new List(); - public static JToken CleanReferences(this Field field, JToken value, ISet oldReferences) + private readonly JToken value; + private readonly ICollection oldReferences; + + private ReferencesCleaner(JToken value, ICollection oldReferences) { - if ((field is AssetsField || field is ReferencesField) && !value.IsNull()) - { - switch (field) - { - case AssetsField assetsField: - return Visit(assetsField, value, oldReferences); - - case ReferencesField referencesField: - return Visit(referencesField, value, oldReferences); - } - } + this.value = value; - return value; + this.oldReferences = oldReferences; } - private static JToken Visit(AssetsField field, JToken value, IEnumerable oldReferences) + public static JToken CleanReferences(IField field, JToken value, ICollection oldReferences) { - var oldIds = GetIds(value); - var newIds = oldIds.Except(oldReferences).ToList(); + return field.Accept(new ReferencesCleaner(value, oldReferences)); + } - return oldIds.Count != newIds.Count ? JToken.FromObject(newIds) : value; + public JToken Visit(IField field) + { + return CleanIds(); } - private static JToken Visit(ReferencesField field, JToken value, ICollection oldReferences) + public JToken Visit(IField field) { if (oldReferences.Contains(field.Properties.SchemaId)) { return new JArray(); } - var oldIds = GetIds(value); - var newIds = oldIds.Except(oldReferences).ToList(); - - return oldIds.Count != newIds.Count ? JToken.FromObject(newIds) : value; + return CleanIds(); } - private static List GetIds(JToken value) + private JToken CleanIds() { - try - { - return value?.ToObject>() ?? EmptyIds; - } - catch + var ids = value.ToGuidSet(); + + var isRemoved = false; + + foreach (var oldReference in oldReferences) { - return EmptyIds; + isRemoved |= ids.Remove(oldReference); } + + return isRemoved ? ids.ToJToken() : value; + } + + public JToken Visit(IField field) + { + return value; + } + + public JToken Visit(IField field) + { + return value; + } + + public JToken Visit(IField field) + { + return value; + } + + public JToken Visit(IField field) + { + return value; + } + + public JToken Visit(IField field) + { + return value; + } + + public JToken Visit(IField field) + { + return value; + } + + public JToken Visit(IField field) + { + return value; + } + + public JToken Visit(IArrayField field) + { + return value; } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesExtensions.cs b/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesExtensions.cs new file mode 100644 index 000000000..b7170f9aa --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesExtensions.cs @@ -0,0 +1,69 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Infrastructure.Json; + +namespace Squidex.Domain.Apps.Core.ExtractReferenceIds +{ + public static class ReferencesExtensions + { + public static IEnumerable ExtractReferences(this IField field, JToken value) + { + return ReferencesExtractor.ExtractReferences(field, value); + } + + public static JToken CleanReferences(this IField field, JToken value, ICollection oldReferences) + { + if (value.IsNull()) + { + return value; + } + + return ReferencesCleaner.CleanReferences(field, value, oldReferences); + } + + public static JToken ToJToken(this HashSet ids) + { + var result = new JArray(); + + foreach (var id in ids) + { + result.Add(new JValue(id)); + } + + return result; + } + + public static HashSet ToGuidSet(this JToken value) + { + if (value is JArray ids) + { + var result = new HashSet(); + + foreach (var id in ids) + { + if (id.Type == JTokenType.Guid) + { + result.Add((Guid)id); + } + else if (id.Type == JTokenType.String && Guid.TryParse((string)id, out var guid)) + { + result.Add(guid); + } + } + + return result; + } + + return new HashSet(); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesExtractor.cs b/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesExtractor.cs index 1bef60ef3..1f661ef9c 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesExtractor.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ReferencesExtractor.cs @@ -13,50 +13,93 @@ using Squidex.Domain.Apps.Core.Schemas; namespace Squidex.Domain.Apps.Core.ExtractReferenceIds { - public static class ReferencesExtractor + public sealed class ReferencesExtractor : IFieldVisitor> { - public static IEnumerable ExtractReferences(this Field field, JToken value) - { - switch (field) - { - case AssetsField assetsField: - return Visit(assetsField, value); + private readonly JToken value; - case ReferencesField referencesField: - return Visit(referencesField, value); - } + private ReferencesExtractor(JToken value) + { + this.value = value; + } - return Enumerable.Empty(); + public static IEnumerable ExtractReferences(IField field, JToken value) + { + return field.Accept(new ReferencesExtractor(value)); } - public static IEnumerable Visit(AssetsField field, JToken value) + public IEnumerable Visit(IArrayField field) { - IEnumerable result; - try - { - result = value?.ToObject>(); - } - catch + var result = new List(); + + if (value is JArray items) { - result = null; + foreach (JObject item in items) + { + foreach (var nestedField in field.Fields) + { + if (item.TryGetValue(nestedField.Name, out var nestedValue)) + { + result.AddRange(nestedField.Accept(new ReferencesExtractor(nestedValue))); + } + } + } } - return result ?? Enumerable.Empty(); + return result; } - private static IEnumerable Visit(ReferencesField field, JToken value) + public IEnumerable Visit(IField field) { - IEnumerable result; - try - { - result = value?.ToObject>() ?? Enumerable.Empty(); - } - catch + var ids = value.ToGuidSet(); + + return ids; + } + + public IEnumerable Visit(IField field) + { + var ids = value.ToGuidSet(); + + if (field.Properties.SchemaId != Guid.Empty) { - result = Enumerable.Empty(); + ids.Add(field.Properties.SchemaId); } - return result.Union(new[] { field.Properties.SchemaId }); + return ids; + } + + public IEnumerable Visit(IField field) + { + return Enumerable.Empty(); + } + + public IEnumerable Visit(IField field) + { + return Enumerable.Empty(); + } + + public IEnumerable Visit(IField field) + { + return Enumerable.Empty(); + } + + public IEnumerable Visit(IField field) + { + return Enumerable.Empty(); + } + + public IEnumerable Visit(IField field) + { + return Enumerable.Empty(); + } + + public IEnumerable Visit(IField field) + { + return Enumerable.Empty(); + } + + public IEnumerable Visit(IField field) + { + return Enumerable.Empty(); } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ValueReferencesConverter.cs b/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ValueReferencesConverter.cs new file mode 100644 index 000000000..e99459cc7 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/ExtractReferenceIds/ValueReferencesConverter.cs @@ -0,0 +1,32 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using Squidex.Domain.Apps.Core.ConvertContent; +using Squidex.Infrastructure.Json; + +namespace Squidex.Domain.Apps.Core.ExtractReferenceIds +{ + public static class ValueReferencesConverter + { + public static ValueConverter CleanReferences(IEnumerable deletedReferencedIds) + { + var ids = new HashSet(deletedReferencedIds); + + return (value, field) => + { + if (value.IsNull()) + { + return value; + } + + return field.CleanReferences(value, ids); + }; + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/GenerateEdmSchema/EdmTypeVisitor.cs b/src/Squidex.Domain.Apps.Core.Operations/GenerateEdmSchema/EdmTypeVisitor.cs index 30f6d3e98..789da3081 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/GenerateEdmSchema/EdmTypeVisitor.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/GenerateEdmSchema/EdmTypeVisitor.cs @@ -18,57 +18,62 @@ namespace Squidex.Domain.Apps.Core.GenerateEdmSchema { } - public static IEdmTypeReference CreateEdmType(Field field) + public static IEdmTypeReference CreateEdmType(IField field) { return field.Accept(Instance); } - public IEdmTypeReference Visit(AssetsField field) + public IEdmTypeReference Visit(IArrayField field) + { + return null; + } + + public IEdmTypeReference Visit(IField field) { return CreatePrimitive(EdmPrimitiveTypeKind.String, field); } - public IEdmTypeReference Visit(BooleanField field) + public IEdmTypeReference Visit(IField field) { return CreatePrimitive(EdmPrimitiveTypeKind.Boolean, field); } - public IEdmTypeReference Visit(DateTimeField field) + public IEdmTypeReference Visit(IField field) { return CreatePrimitive(EdmPrimitiveTypeKind.DateTimeOffset, field); } - public IEdmTypeReference Visit(GeolocationField field) + public IEdmTypeReference Visit(IField field) { return null; } - public IEdmTypeReference Visit(JsonField field) + public IEdmTypeReference Visit(IField field) { return null; } - public IEdmTypeReference Visit(NumberField field) + public IEdmTypeReference Visit(IField field) { return CreatePrimitive(EdmPrimitiveTypeKind.Double, field); } - public IEdmTypeReference Visit(ReferencesField field) + public IEdmTypeReference Visit(IField field) { return CreatePrimitive(EdmPrimitiveTypeKind.String, field); } - public IEdmTypeReference Visit(StringField field) + public IEdmTypeReference Visit(IField field) { return CreatePrimitive(EdmPrimitiveTypeKind.String, field); } - public IEdmTypeReference Visit(TagsField field) + public IEdmTypeReference Visit(IField field) { return CreatePrimitive(EdmPrimitiveTypeKind.String, field); } - private static IEdmTypeReference CreatePrimitive(EdmPrimitiveTypeKind kind, Field field) + private static IEdmTypeReference CreatePrimitive(EdmPrimitiveTypeKind kind, IField field) { return EdmCoreModel.Instance.GetPrimitive(kind, !field.RawProperties.IsRequired); } diff --git a/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonSchemaExtensions.cs b/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonSchemaExtensions.cs index ff78f3e64..a11185325 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonSchemaExtensions.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonSchemaExtensions.cs @@ -36,6 +36,8 @@ namespace Squidex.Domain.Apps.Core.GenerateJsonSchema var partitionItemProperty = field.Accept(jsonTypeVisitor); partitionItemProperty.Description = partitionItem.Name; + partitionItemProperty.IsRequired = field.RawProperties.IsRequired && !partitionItem.IsOptional; + partitionObject.Properties.Add(partitionItem.Key, partitionItemProperty); } @@ -47,24 +49,19 @@ namespace Squidex.Domain.Apps.Core.GenerateJsonSchema return jsonSchema; } - public static JsonProperty CreateProperty(Field field) + public static JsonProperty CreateProperty(IField field) { var jsonProperty = new JsonProperty { IsRequired = field.RawProperties.IsRequired, Type = JsonObjectType.Object }; if (!string.IsNullOrWhiteSpace(field.RawProperties.Hints)) { - jsonProperty.Description = field.RawProperties.Hints; + jsonProperty.Description = $"{field.Name} ({field.RawProperties.Hints})"; } else { jsonProperty.Description = field.Name; } - if (!string.IsNullOrWhiteSpace(field.RawProperties.Hints)) - { - jsonProperty.Description += $" ({field.RawProperties.Hints})."; - } - return jsonProperty; } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonTypeVisitor.cs b/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonTypeVisitor.cs index 06c5095b5..00ad5f60f 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonTypeVisitor.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/GenerateJsonSchema/JsonTypeVisitor.cs @@ -7,6 +7,7 @@ using System; using System.Collections.ObjectModel; +using System.Linq; using NJsonSchema; using Squidex.Domain.Apps.Core.Schemas; @@ -21,7 +22,31 @@ namespace Squidex.Domain.Apps.Core.GenerateJsonSchema this.schemaResolver = schemaResolver; } - public JsonProperty Visit(AssetsField field) + public JsonProperty Visit(IArrayField field) + { + return CreateProperty(field, jsonProperty => + { + var itemSchema = new JsonSchema4 + { + Type = JsonObjectType.Object + }; + + foreach (var nestedField in field.Fields.Where(x => !x.IsHidden)) + { + var childProperty = nestedField.Accept(this); + + childProperty.Description = nestedField.RawProperties.Hints; + childProperty.IsRequired = nestedField.RawProperties.IsRequired; + + itemSchema.Properties.Add(nestedField.Name, childProperty); + } + + jsonProperty.Type = JsonObjectType.Object; + jsonProperty.Item = itemSchema; + }); + } + + public JsonProperty Visit(IField field) { return CreateProperty(field, jsonProperty => { @@ -32,7 +57,7 @@ namespace Squidex.Domain.Apps.Core.GenerateJsonSchema }); } - public JsonProperty Visit(BooleanField field) + public JsonProperty Visit(IField field) { return CreateProperty(field, jsonProperty => { @@ -40,7 +65,7 @@ namespace Squidex.Domain.Apps.Core.GenerateJsonSchema }); } - public JsonProperty Visit(DateTimeField field) + public JsonProperty Visit(IField field) { return CreateProperty(field, jsonProperty => { @@ -49,7 +74,7 @@ namespace Squidex.Domain.Apps.Core.GenerateJsonSchema }); } - public JsonProperty Visit(GeolocationField field) + public JsonProperty Visit(IField field) { return CreateProperty(field, jsonProperty => { @@ -81,7 +106,7 @@ namespace Squidex.Domain.Apps.Core.GenerateJsonSchema }); } - public JsonProperty Visit(JsonField field) + public JsonProperty Visit(IField field) { return CreateProperty(field, jsonProperty => { @@ -89,7 +114,7 @@ namespace Squidex.Domain.Apps.Core.GenerateJsonSchema }); } - public JsonProperty Visit(NumberField field) + public JsonProperty Visit(IField field) { return CreateProperty(field, jsonProperty => { @@ -107,7 +132,7 @@ namespace Squidex.Domain.Apps.Core.GenerateJsonSchema }); } - public JsonProperty Visit(ReferencesField field) + public JsonProperty Visit(IField field) { return CreateProperty(field, jsonProperty => { @@ -118,7 +143,7 @@ namespace Squidex.Domain.Apps.Core.GenerateJsonSchema }); } - public JsonProperty Visit(StringField field) + public JsonProperty Visit(IField field) { return CreateProperty(field, jsonProperty => { @@ -139,7 +164,7 @@ namespace Squidex.Domain.Apps.Core.GenerateJsonSchema }); } - public JsonProperty Visit(TagsField field) + public JsonProperty Visit(IField field) { return CreateProperty(field, jsonProperty => { @@ -150,7 +175,7 @@ namespace Squidex.Domain.Apps.Core.GenerateJsonSchema }); } - private static JsonProperty CreateProperty(Field field, Action updater) + private static JsonProperty CreateProperty(IField field, Action updater) { var property = new JsonProperty { IsRequired = field.RawProperties.IsRequired }; diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AlgoliaActionHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AlgoliaActionHandler.cs deleted file mode 100644 index 49193c29d..000000000 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AlgoliaActionHandler.cs +++ /dev/null @@ -1,159 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Threading.Tasks; -using Algolia.Search; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using Squidex.Domain.Apps.Core.Contents; -using Squidex.Domain.Apps.Core.Rules; -using Squidex.Domain.Apps.Core.Rules.Actions; -using Squidex.Domain.Apps.Events; -using Squidex.Domain.Apps.Events.Contents; -using Squidex.Infrastructure; -using Squidex.Infrastructure.EventSourcing; - -namespace Squidex.Domain.Apps.Core.HandleRules.Actions -{ - public sealed class AlgoliaActionHandler : RuleActionHandler - { - private readonly ClientPool<(string AppId, string ApiKey, string IndexName), Index> clients; - private readonly RuleEventFormatter formatter; - - public AlgoliaActionHandler(RuleEventFormatter formatter) - { - Guard.NotNull(formatter, nameof(formatter)); - - this.formatter = formatter; - - clients = new ClientPool<(string AppId, string ApiKey, string IndexName), Index>(key => - { - var client = new AlgoliaClient(key.AppId, key.ApiKey); - - return client.InitIndex(key.IndexName); - }); - } - - protected override (string Description, RuleJobData Data) CreateJob(Envelope @event, string eventName, AlgoliaAction action) - { - var ruleDescription = string.Empty; - var ruleData = new RuleJobData - { - ["AppId"] = action.AppId, - ["ApiKey"] = action.ApiKey - }; - - if (@event.Payload is ContentEvent contentEvent) - { - ruleData["ContentId"] = contentEvent.ContentId.ToString(); - ruleData["Operation"] = "Upsert"; - ruleData["IndexName"] = formatter.FormatString(action.IndexName, @event); - - var timestamp = @event.Headers.Timestamp().ToString(); - - switch (@event.Payload) - { - case ContentCreated created: - { - ruleDescription = $"Add entry to Algolia index: {action.IndexName}"; - - ruleData["Content"] = new JObject( - new JProperty("id", contentEvent.ContentId), - new JProperty("created", timestamp), - new JProperty("createdBy", created.Actor.ToString()), - new JProperty("lastModified", timestamp), - new JProperty("lastModifiedBy", created.Actor.ToString()), - new JProperty("status", Status.Draft.ToString()), - new JProperty("data", formatter.ToRouteData(created.Data))); - break; - } - - case ContentUpdated updated: - { - ruleDescription = $"Update entry in Algolia index: {action.IndexName}"; - - ruleData["Content"] = new JObject( - new JProperty("lastModified", timestamp), - new JProperty("lastModifiedBy", updated.Actor.ToString()), - new JProperty("data", formatter.ToRouteData(updated.Data))); - break; - } - - case ContentStatusChanged statusChanged: - { - ruleDescription = $"Update entry in Algolia index: {action.IndexName}"; - - ruleData["Content"] = new JObject( - new JProperty("lastModified", timestamp), - new JProperty("lastModifiedBy", statusChanged.Actor.ToString()), - new JProperty("status", statusChanged.Status.ToString())); - break; - } - - case ContentDeleted deleted: - { - ruleDescription = $"Delete entry from Algolia index: {action.IndexName}"; - - ruleData["Content"] = new JObject(); - ruleData["Operation"] = "Delete"; - break; - } - } - } - - return (ruleDescription, ruleData); - } - - public override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(RuleJobData job) - { - if (!job.TryGetValue("Operation", out var operationToken)) - { - return (null, new InvalidOperationException("The action cannot handle this event.")); - } - - var appId = job["AppId"].Value(); - var apiKey = job["ApiKey"].Value(); - var indexName = job["IndexName"].Value(); - - var index = clients.GetClient((appId, apiKey, indexName)); - - var operation = operationToken.Value(); - var content = job["Content"].Value(); - var contentId = job["ContentId"].Value(); - - try - { - switch (operation) - { - case "Upsert": - { - content["objectID"] = contentId; - - var response = await index.PartialUpdateObjectAsync(content); - - return (response.ToString(Formatting.Indented), null); - } - - case "Delete": - { - var response = await index.DeleteObjectAsync(contentId); - - return (response.ToString(Formatting.Indented), null); - } - - default: - return (null, null); - } - } - catch (AlgoliaException ex) - { - return (ex.Message, ex); - } - } - } -} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AzureQueueActionHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AzureQueueActionHandler.cs deleted file mode 100644 index 56dd3154a..000000000 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AzureQueueActionHandler.cs +++ /dev/null @@ -1,73 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Threading.Tasks; -using Microsoft.WindowsAzure.Storage; -using Microsoft.WindowsAzure.Storage.Queue; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using Squidex.Domain.Apps.Core.Rules; -using Squidex.Domain.Apps.Core.Rules.Actions; -using Squidex.Domain.Apps.Events; -using Squidex.Infrastructure; -using Squidex.Infrastructure.EventSourcing; - -namespace Squidex.Domain.Apps.Core.HandleRules.Actions -{ - public sealed class AzureQueueActionHandler : RuleActionHandler - { - private readonly ClientPool<(string ConnectionString, string QueueName), CloudQueue> clients; - private readonly RuleEventFormatter formatter; - - public AzureQueueActionHandler(RuleEventFormatter formatter) - { - Guard.NotNull(formatter, nameof(formatter)); - - this.formatter = formatter; - - clients = new ClientPool<(string ConnectionString, string QueueName), CloudQueue>(key => - { - var storageAccount = CloudStorageAccount.Parse(key.ConnectionString); - - var queueClient = storageAccount.CreateCloudQueueClient(); - var queueRef = queueClient.GetQueueReference(key.QueueName); - - return queueRef; - }); - } - - protected override (string Description, RuleJobData Data) CreateJob(Envelope @event, string eventName, AzureQueueAction action) - { - var body = formatter.ToRouteData(@event, eventName); - - var ruleDescription = $"Send event to azure queue '{action.Queue}'"; - var ruleData = new RuleJobData - { - ["QueueConnectionString"] = action.ConnectionString, - ["QueueName"] = action.Queue, - ["MessageBody"] = body - }; - - return (ruleDescription, ruleData); - } - - public override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(RuleJobData job) - { - var queueConnectionString = job["QueueConnectionString"].Value(); - var queueName = job["QueueName"].Value(); - - var queue = clients.GetClient((queueConnectionString, queueName)); - - var messageBody = job["MessageBody"].ToString(Formatting.Indented); - - await queue.AddMessageAsync(new CloudQueueMessage(messageBody)); - - return ("Completed", null); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/ElasticSearchActionHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/ElasticSearchActionHandler.cs deleted file mode 100644 index 5ef0359bc..000000000 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/ElasticSearchActionHandler.cs +++ /dev/null @@ -1,181 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Threading.Tasks; -using Elasticsearch.Net; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using Squidex.Domain.Apps.Core.Contents; -using Squidex.Domain.Apps.Core.Rules; -using Squidex.Domain.Apps.Core.Rules.Actions; -using Squidex.Domain.Apps.Events; -using Squidex.Domain.Apps.Events.Contents; -using Squidex.Infrastructure; -using Squidex.Infrastructure.EventSourcing; - -namespace Squidex.Domain.Apps.Core.HandleRules.Actions -{ - public sealed class ElasticSearchActionHandler : RuleActionHandler - { - private readonly ClientPool<(Uri Host, string Username, string Password), ElasticLowLevelClient> clients; - private readonly RuleEventFormatter formatter; - - public ElasticSearchActionHandler(RuleEventFormatter formatter) - { - Guard.NotNull(formatter, nameof(formatter)); - - this.formatter = formatter; - - clients = new ClientPool<(Uri Host, string Username, string Password), ElasticLowLevelClient>(key => - { - var config = new ConnectionConfiguration(key.Host); - - if (!string.IsNullOrEmpty(key.Username) && !string.IsNullOrWhiteSpace(key.Password)) - { - config = config.BasicAuthentication(key.Username, key.Password); - } - - return new ElasticLowLevelClient(config); - }); - } - - protected override (string Description, RuleJobData Data) CreateJob(Envelope @event, string eventName, ElasticSearchAction action) - { - var ruleDescription = string.Empty; - var ruleData = new RuleJobData - { - ["Host"] = action.Host, - ["Username"] = action.Username, - ["Password"] = action.Password - }; - - if (@event.Payload is ContentEvent contentEvent) - { - ruleData["ContentId"] = contentEvent.ContentId.ToString(); - ruleData["IndexName"] = formatter.FormatString(action.IndexName, @event); - ruleData["IndexType"] = formatter.FormatString(action.IndexType, @event); - - var timestamp = @event.Headers.Timestamp().ToString(); - - switch (@event.Payload) - { - case ContentCreated created: - { - ruleDescription = $"Add entry to ES index: {action.IndexName}"; - - ruleData["Operation"] = "Create"; - ruleData["Content"] = new JObject( - new JProperty("id", contentEvent.ContentId), - new JProperty("created", timestamp), - new JProperty("createdBy", created.Actor.ToString()), - new JProperty("lastModified", timestamp), - new JProperty("lastModifiedBy", created.Actor.ToString()), - new JProperty("status", Status.Draft.ToString()), - new JProperty("data", formatter.ToRouteData(created.Data))); - break; - } - - case ContentUpdated updated: - { - ruleDescription = $"Update entry in ES index: {action.IndexName}"; - - ruleData["Operation"] = "Update"; - ruleData["Content"] = new JObject( - new JProperty("lastModified", timestamp), - new JProperty("lastModifiedBy", updated.Actor.ToString()), - new JProperty("data", formatter.ToRouteData(updated.Data))); - break; - } - - case ContentStatusChanged statusChanged: - { - ruleDescription = $"Update entry in ES index: {action.IndexName}"; - - ruleData["Operation"] = "Update"; - ruleData["Content"] = new JObject( - new JProperty("lastModified", timestamp), - new JProperty("lastModifiedBy", statusChanged.Actor.ToString()), - new JProperty("status", statusChanged.Status.ToString())); - break; - } - - case ContentDeleted deleted: - { - ruleDescription = $"Delete entry from ES index: {action.IndexName}"; - - ruleData["Operation"] = "Delete"; - ruleData["Content"] = new JObject(); - break; - } - } - } - - return (ruleDescription, ruleData); - } - - public override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(RuleJobData job) - { - if (!job.TryGetValue("Operation", out var operationToken)) - { - return (null, new InvalidOperationException("The action cannot handle this event.")); - } - - var host = new Uri(job["Host"].Value(), UriKind.Absolute); - - var username = job["Username"].Value(); - var password = job["Password"].Value(); - - var client = clients.GetClient((host, username, password)); - - var indexName = job["IndexName"].Value(); - var indexType = job["IndexType"].Value(); - - var operation = operationToken.Value(); - var content = job["Content"].Value(); - var contentId = job["ContentId"].Value(); - - try - { - switch (operation) - { - case "Create": - { - var doc = content.ToString(); - - var response = await client.IndexAsync(indexName, indexType, contentId, doc); - - return (response.Body, response.OriginalException); - } - - case "Update": - { - var doc = new JObject(new JProperty("doc", content)).ToString(); - - var response = await client.UpdateAsync(indexName, indexType, contentId, doc); - - return (response.Body, response.OriginalException); - } - - case "Delete": - { - var response = await client.DeleteAsync(indexName, indexType, contentId); - - return (response.Body, response.OriginalException); - } - - default: - return (null, null); - } - } - catch (ElasticsearchClientException ex) - { - return (ex.Message, ex); - } - } - } -} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/FastlyActionHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/FastlyActionHandler.cs deleted file mode 100644 index 5a8a252bd..000000000 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/FastlyActionHandler.cs +++ /dev/null @@ -1,89 +0,0 @@ -// ========================================================================== -// 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.Net.Http; -using System.Threading.Tasks; -using Newtonsoft.Json.Linq; -using Squidex.Domain.Apps.Core.Rules; -using Squidex.Domain.Apps.Core.Rules.Actions; -using Squidex.Domain.Apps.Events; -using Squidex.Infrastructure.EventSourcing; -using Squidex.Infrastructure.Http; - -namespace Squidex.Domain.Apps.Core.HandleRules.Actions -{ - public sealed class FastlyActionHandler : RuleActionHandler - { - protected override (string Description, RuleJobData Data) CreateJob(Envelope @event, string eventName, FastlyAction action) - { - var ruleDescription = "Purge key in fastly"; - var ruleData = new RuleJobData - { - ["FastlyApiKey"] = action.ApiKey, - ["FastlyServiceID"] = action.ServiceId - }; - - if (@event.Headers.Contains(CommonHeaders.AggregateId)) - { - ruleData["Key"] = @event.Headers.AggregateId().ToString(); - } - - return (ruleDescription, ruleData); - } - - public override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(RuleJobData job) - { - if (!job.TryGetValue("Key", out var keyToken)) - { - return (null, new InvalidOperationException("The action cannot handle this event.")); - } - - var requestMsg = BuildRequest(job, keyToken.Value()); - - HttpResponseMessage response = null; - - try - { - response = await HttpClientPool.GetHttpClient().SendAsync(requestMsg); - - var responseString = await response.Content.ReadAsStringAsync(); - var requestDump = DumpFormatter.BuildDump(requestMsg, response, null, responseString, TimeSpan.Zero, false); - - return (requestDump, null); - } - catch (Exception ex) - { - if (requestMsg != null) - { - var requestDump = DumpFormatter.BuildDump(requestMsg, response, null, ex.ToString(), TimeSpan.Zero, false); - - return (requestDump, ex); - } - else - { - var requestDump = ex.ToString(); - - return (requestDump, ex); - } - } - } - - private static HttpRequestMessage BuildRequest(Dictionary job, string key) - { - var serviceId = job["FastlyServiceID"].Value(); - - var requestUrl = $"https://api.fastly.com/service/{serviceId}/purge/{key}"; - var request = new HttpRequestMessage(HttpMethod.Post, requestUrl); - - request.Headers.Add("Fastly-Key", job["FastlyApiKey"].Value()); - - return request; - } - } -} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/SlackActionHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/SlackActionHandler.cs deleted file mode 100644 index 4c341df54..000000000 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/SlackActionHandler.cs +++ /dev/null @@ -1,97 +0,0 @@ -// ========================================================================== -// 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.Net.Http; -using System.Text; -using System.Threading.Tasks; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using Squidex.Domain.Apps.Core.Rules; -using Squidex.Domain.Apps.Core.Rules.Actions; -using Squidex.Domain.Apps.Events; -using Squidex.Infrastructure; -using Squidex.Infrastructure.EventSourcing; -using Squidex.Infrastructure.Http; - -namespace Squidex.Domain.Apps.Core.HandleRules.Actions -{ - public sealed class SlackActionHandler : RuleActionHandler - { - private readonly RuleEventFormatter formatter; - - public SlackActionHandler(RuleEventFormatter formatter) - { - Guard.NotNull(formatter, nameof(formatter)); - - this.formatter = formatter; - } - - protected override (string Description, RuleJobData Data) CreateJob(Envelope @event, string eventName, SlackAction action) - { - var body = CreatePayload(@event, action.Text); - - var ruleDescription = "Send message to slack"; - var ruleData = new RuleJobData - { - ["RequestUrl"] = action.WebhookUrl, - ["RequestBody"] = body - }; - - return (ruleDescription, ruleData); - } - - private JObject CreatePayload(Envelope @event, string text) - { - return new JObject(new JProperty("text", formatter.FormatString(text, @event))); - } - - public override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(RuleJobData job) - { - var requestBody = job["RequestBody"].ToString(Formatting.Indented); - var requestMsg = BuildRequest(job, requestBody); - - HttpResponseMessage response = null; - - try - { - response = await HttpClientPool.GetHttpClient().SendAsync(requestMsg); - - var responseString = await response.Content.ReadAsStringAsync(); - var requestDump = DumpFormatter.BuildDump(requestMsg, response, requestBody, responseString, TimeSpan.Zero, false); - - return (requestDump, null); - } - catch (Exception ex) - { - if (requestMsg != null) - { - var requestDump = DumpFormatter.BuildDump(requestMsg, response, requestBody, ex.ToString(), TimeSpan.Zero, false); - - return (requestDump, ex); - } - else - { - throw; - } - } - } - - private static HttpRequestMessage BuildRequest(Dictionary job, string requestBody) - { - var requestUrl = job["RequestUrl"].Value(); - - var request = new HttpRequestMessage(HttpMethod.Post, requestUrl) - { - Content = new StringContent(requestBody, Encoding.UTF8, "application/json") - }; - - return request; - } - } -} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/WebhookActionHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/WebhookActionHandler.cs deleted file mode 100644 index 5fe661d4f..000000000 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/WebhookActionHandler.cs +++ /dev/null @@ -1,99 +0,0 @@ -// ========================================================================== -// 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.Net.Http; -using System.Text; -using System.Threading.Tasks; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using Squidex.Domain.Apps.Core.Rules; -using Squidex.Domain.Apps.Core.Rules.Actions; -using Squidex.Domain.Apps.Events; -using Squidex.Infrastructure; -using Squidex.Infrastructure.EventSourcing; -using Squidex.Infrastructure.Http; - -namespace Squidex.Domain.Apps.Core.HandleRules.Actions -{ - public sealed class WebhookActionHandler : RuleActionHandler - { - private readonly RuleEventFormatter formatter; - - public WebhookActionHandler(RuleEventFormatter formatter) - { - Guard.NotNull(formatter, nameof(formatter)); - - this.formatter = formatter; - } - - protected override (string Description, RuleJobData Data) CreateJob(Envelope @event, string eventName, WebhookAction action) - { - var body = formatter.ToRouteData(@event, eventName); - - var signature = $"{body.ToString(Formatting.Indented)}{action.SharedSecret}".Sha256Base64(); - - var ruleDescription = $"Send event to webhook '{action.Url}'"; - var ruleData = new RuleJobData - { - ["RequestUrl"] = action.Url, - ["RequestBody"] = body, - ["RequestSignature"] = signature - }; - - return (ruleDescription, ruleData); - } - - public override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(RuleJobData job) - { - var requestBody = job["RequestBody"].ToString(Formatting.Indented); - var requestMsg = BuildRequest(job, requestBody); - - HttpResponseMessage response = null; - - try - { - response = await HttpClientPool.GetHttpClient().SendAsync(requestMsg); - - var responseString = await response.Content.ReadAsStringAsync(); - var requestDump = DumpFormatter.BuildDump(requestMsg, response, requestBody, responseString, TimeSpan.Zero, false); - - return (requestDump, null); - } - catch (Exception ex) - { - if (requestMsg != null) - { - var requestDump = DumpFormatter.BuildDump(requestMsg, response, requestBody, ex.ToString(), TimeSpan.Zero, false); - - return (requestDump, ex); - } - else - { - throw; - } - } - } - - private static HttpRequestMessage BuildRequest(Dictionary job, string requestBody) - { - var requestUrl = job["RequestUrl"].Value(); - var requestSig = job["RequestSignature"].Value(); - - var request = new HttpRequestMessage(HttpMethod.Post, requestUrl) - { - Content = new StringContent(requestBody, Encoding.UTF8, "application/json") - }; - - request.Headers.Add("X-Signature", requestSig); - request.Headers.Add("User-Agent", "Squidex Webhook"); - - return request; - } - } -} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/ClientPool.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/ClientPool.cs index b126e4bf3..b93a45a25 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/ClientPool.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/ClientPool.cs @@ -6,6 +6,7 @@ // ========================================================================== using System; +using System.Threading.Tasks; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; @@ -17,18 +18,28 @@ namespace Squidex.Domain.Apps.Core.HandleRules { private static readonly TimeSpan TTL = TimeSpan.FromMinutes(30); private readonly MemoryCache memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions())); - private readonly Func factory; + private readonly Func> factory; public ClientPool(Func factory) + { + this.factory = x => Task.FromResult(factory(x)); + } + + public ClientPool(Func> factory) { this.factory = factory; } public TClient GetClient(TKey key) + { + return GetClientAsync(key).Result; + } + + public async Task GetClientAsync(TKey key) { if (!memoryCache.TryGetValue(key, out var client)) { - client = factory(key); + client = await factory(key); memoryCache.Set(key, client, TTL); } diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedAssetEvent.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedAssetEvent.cs new file mode 100644 index 000000000..56e0303b0 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedAssetEvent.cs @@ -0,0 +1,47 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using NodaTime; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents +{ + public sealed class EnrichedAssetEvent : EnrichedEvent + { + public EnrichedAssetEventType Type { get; set; } + + public Guid Id { get; set; } + + public Instant Created { get; set; } + + public Instant LastModified { get; set; } + + public RefToken CreatedBy { get; set; } + + public RefToken LastModifiedBy { get; set; } + + public string MimeType { get; set; } + + public string FileName { get; set; } + + public long FileVersion { get; set; } + + public long FileSize { get; set; } + + public bool IsImage { get; set; } + + public int? PixelWidth { get; set; } + + public int? PixelHeight { get; set; } + + public override Guid AggregateId + { + get { return Id; } + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedAssetEventType.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedAssetEventType.cs new file mode 100644 index 000000000..0e66499b2 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedAssetEventType.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents +{ + public enum EnrichedAssetEventType + { + Created, + Deleted, + Renamed, + Updated + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedContentEvent.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedContentEvent.cs new file mode 100644 index 000000000..88af9945c --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedContentEvent.cs @@ -0,0 +1,38 @@ +// ========================================================================== +// 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.Infrastructure; + +namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents +{ + public sealed class EnrichedContentEvent : EnrichedSchemaEvent + { + public EnrichedContentEventType Type { get; set; } + + public Guid Id { 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 override Guid AggregateId + { + get { return Id; } + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedContentEventType.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedContentEventType.cs new file mode 100644 index 000000000..45148a8e2 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedContentEventType.cs @@ -0,0 +1,20 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents +{ + public enum EnrichedContentEventType + { + Archived, + Created, + Deleted, + Published, + Restored, + Unpublished, + Updated + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedEvent.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedEvent.cs new file mode 100644 index 000000000..b95a939bf --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedEvent.cs @@ -0,0 +1,35 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Newtonsoft.Json; +using NodaTime; +using Squidex.Infrastructure; +using Squidex.Shared.Users; + +namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents +{ + public abstract class EnrichedEvent + { + public NamedId AppId { get; set; } + + public RefToken Actor { get; set; } + + public Instant Timestamp { get; set; } + + public long Version { get; set; } + + [JsonIgnore] + public abstract Guid AggregateId { get; } + + [JsonIgnore] + public string Name { get; set; } + + [JsonIgnore] + public IUser User { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedSchemaEvent.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedSchemaEvent.cs new file mode 100644 index 000000000..528121c3b --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedSchemaEvent.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents +{ + public abstract class EnrichedSchemaEvent : EnrichedEvent + { + public NamedId SchemaId { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/HttpClientPool.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/HttpClientPool.cs deleted file mode 100644 index 231920699..000000000 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/HttpClientPool.cs +++ /dev/null @@ -1,25 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Net.Http; - -namespace Squidex.Domain.Apps.Core.HandleRules -{ - public static class HttpClientPool - { - private static readonly ClientPool Pool = new ClientPool(key => - { - return new HttpClient { Timeout = TimeSpan.FromSeconds(2) }; - }); - - public static HttpClient GetHttpClient() - { - return Pool.GetClient(string.Empty); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IContentResolver.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IContentResolver.cs new file mode 100644 index 000000000..d25b17ea4 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IContentResolver.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.Contents; + +namespace Squidex.Domain.Apps.Core.HandleRules +{ + public interface IContentResolver + { + Task GetContentDataAsync(Guid id); + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IEventEnricher.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IEventEnricher.cs new file mode 100644 index 000000000..6d2e7961d --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IEventEnricher.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents; +using Squidex.Domain.Apps.Events; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Core.HandleRules +{ + public interface IEventEnricher + { + Task EnrichAsync(Envelope @event); + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleActionHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleActionHandler.cs index 75f7e7fbe..deced9228 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleActionHandler.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleActionHandler.cs @@ -7,9 +7,9 @@ using System; using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules; -using Squidex.Domain.Apps.Events; -using Squidex.Infrastructure.EventSourcing; namespace Squidex.Domain.Apps.Core.HandleRules { @@ -17,8 +17,8 @@ namespace Squidex.Domain.Apps.Core.HandleRules { Type ActionType { get; } - (string Description, RuleJobData Data) CreateJob(Envelope @event, string eventName, RuleAction action); + Task<(string Description, JObject Data)> CreateJobAsync(EnrichedEvent @event, RuleAction action); - Task<(string Dump, Exception Exception)> ExecuteJobAsync(RuleJobData data); + Task<(string Dump, Exception Exception)> ExecuteJobAsync(JObject data); } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleUrlGenerator.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleUrlGenerator.cs new file mode 100644 index 000000000..51698c4b9 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleUrlGenerator.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.HandleRules +{ + public interface IRuleUrlGenerator + { + string GenerateContentUIUrl(NamedId appId, NamedId schemaId, Guid contentId); + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionHandler.cs index 22a41360d..0143f1956 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionHandler.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionHandler.cs @@ -7,26 +7,85 @@ using System; using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules; -using Squidex.Domain.Apps.Events; -using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure; + +#pragma warning disable RECS0083 // Shows NotImplementedException throws in the quick task bar namespace Squidex.Domain.Apps.Core.HandleRules { - public abstract class RuleActionHandler : IRuleActionHandler where T : RuleAction + public abstract class RuleActionHandler : IRuleActionHandler where TAction : RuleAction { + private readonly RuleEventFormatter formatter; + Type IRuleActionHandler.ActionType { - get { return typeof(T); } + get { return typeof(TAction); } + } + + protected RuleActionHandler(RuleEventFormatter formatter) + { + Guard.NotNull(formatter, nameof(formatter)); + + this.formatter = formatter; + } + + protected virtual string ToPayloadJson(T @event) + { + return formatter.ToPayload(@event).ToString(); + } + + protected virtual string ToEnvelopeJson(EnrichedEvent @event) + { + return formatter.ToEnvelope(@event).ToString(); + } + + protected virtual JObject ToPayload(T @event) + { + return formatter.ToPayload(@event); + } + + protected virtual JObject ToEnvelope(EnrichedEvent @event) + { + return formatter.ToEnvelope(@event); } - (string Description, RuleJobData Data) IRuleActionHandler.CreateJob(Envelope @event, string eventName, RuleAction action) + protected string Format(Uri uri, EnrichedEvent @event) { - return CreateJob(@event, eventName, (T)action); + return formatter.Format(uri.ToString(), @event); } - protected abstract (string Description, RuleJobData Data) CreateJob(Envelope @event, string eventName, T action); + protected string Format(string text, EnrichedEvent @event) + { + return formatter.Format(text, @event); + } + + async Task<(string Description, JObject Data)> IRuleActionHandler.CreateJobAsync(EnrichedEvent @event, RuleAction action) + { + var (description, data) = await CreateJobAsync(@event, (TAction)action); + + return (description, JObject.FromObject(data)); + } + + async Task<(string Dump, Exception Exception)> IRuleActionHandler.ExecuteJobAsync(JObject data) + { + var typedData = data.ToObject(); + + return await ExecuteJobAsync(typedData); + } + + protected virtual Task<(string Description, TData Data)> CreateJobAsync(EnrichedEvent @event, TAction action) + { + return Task.FromResult(CreateJob(@event, action)); + } + + protected virtual (string Description, TData Data) CreateJob(EnrichedEvent @event, TAction action) + { + throw new NotImplementedException(); + } - public abstract Task<(string Dump, Exception Exception)> ExecuteJobAsync(RuleJobData job); + protected abstract Task<(string Dump, Exception Exception)> ExecuteJobAsync(TData job); } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs index 0e64960f9..895a722ae 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs @@ -5,166 +5,291 @@ // All rights reserved. Licensed under the MIT license. // =========================================-================================= +using System; +using System.Collections.Generic; using System.Globalization; using System.Text; using System.Text.RegularExpressions; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Squidex.Domain.Apps.Core.Contents; -using Squidex.Domain.Apps.Events; -using Squidex.Domain.Apps.Events.Contents; +using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents; using Squidex.Infrastructure; -using Squidex.Infrastructure.EventSourcing; +using Squidex.Shared.Users; namespace Squidex.Domain.Apps.Core.HandleRules { public class RuleEventFormatter { private const string Undefined = "UNDEFINED"; - private const string AppIdPlaceholder = "$APP_ID"; - private const string AppNamePlaceholder = "$APP_NAME"; - private const string SchemaIdPlaceholder = "$SCHEMA_ID"; - private const string SchemaNamePlaceholder = "$SCHEMA_NAME"; - private const string TimestampDatePlaceholder = "$TIMESTAMP_DATE"; - private const string TimestampDateTimePlaceholder = "$TIMESTAMP_DATETIME"; - private const string ContentActionPlaceholder = "$CONTENT_ACTION"; - private static readonly Regex ContentDataPlaceholder = new Regex(@"\$CONTENT_DATA(\.([0-9A-Za-z\-_]*)){2,}", RegexOptions.Compiled); + private static readonly char[] ContentPlaceholderStartOld = "CONTENT_DATA".ToCharArray(); + private static readonly char[] ContentPlaceholderStartNew = "{CONTENT_DATA".ToCharArray(); + private static readonly Regex ContentDataPlaceholderOld = new Regex(@"^CONTENT_DATA(\.([0-9A-Za-z\-_]*)){2,}", RegexOptions.Compiled); + private static readonly Regex ContentDataPlaceholderNew = new Regex(@"^\{CONTENT_DATA(\.([0-9A-Za-z\-_]*)){2,}\}", RegexOptions.Compiled); + private readonly List<(char[] Pattern, Func Replacer)> patterns = new List<(char[] Pattern, Func Replacer)>(); private readonly JsonSerializer serializer; + private readonly IRuleUrlGenerator urlGenerator; - public RuleEventFormatter(JsonSerializer serializer) + public RuleEventFormatter(JsonSerializer serializer, IRuleUrlGenerator urlGenerator) { Guard.NotNull(serializer, nameof(serializer)); + Guard.NotNull(urlGenerator, nameof(urlGenerator)); this.serializer = serializer; + this.urlGenerator = urlGenerator; + + AddPattern("APP_ID", AppId); + AddPattern("APP_NAME", AppName); + AddPattern("CONTENT_ACTION", ContentAction); + AddPattern("CONTENT_URL", ContentUrl); + AddPattern("SCHEMA_ID", SchemaId); + AddPattern("SCHEMA_NAME", SchemaName); + AddPattern("TIMESTAMP_DATETIME", TimestampTime); + AddPattern("TIMESTAMP_DATE", TimestampDate); + AddPattern("USER_NAME", UserName); + AddPattern("USER_EMAIL", UserEmail); } - public virtual JToken ToRouteData(object value) + private void AddPattern(string placeholder, Func generator) { - return JToken.FromObject(value, serializer); + patterns.Add((placeholder.ToCharArray(), generator)); } - public virtual JToken ToRouteData(Envelope @event, string eventName) + public virtual JObject ToPayload(T @event) { - return new JObject( - new JProperty("type", eventName), - new JProperty("payload", JToken.FromObject(@event.Payload, serializer)), - new JProperty("timestamp", @event.Headers.Timestamp().ToString())); + return JObject.FromObject(@event, serializer); } - public virtual string FormatString(string text, Envelope @event) + public virtual JObject ToEnvelope(EnrichedEvent @event) { - var sb = new StringBuilder(text); + return new JObject( + new JProperty("type", @event.Name), + new JProperty("payload", ToPayload(@event)), + new JProperty("timestamp", @event.Timestamp.ToString())); + } - if (@event.Headers.Contains(CommonHeaders.Timestamp)) + public string Format(string text, EnrichedEvent @event) + { + if (string.IsNullOrWhiteSpace(text)) { - var timestamp = @event.Headers.Timestamp().ToDateTimeUtc(); - - sb.Replace(TimestampDateTimePlaceholder, timestamp.ToString("yyy-MM-dd-hh-mm-ss", CultureInfo.InvariantCulture)); - sb.Replace(TimestampDatePlaceholder, timestamp.ToString("yyy-MM-dd", CultureInfo.InvariantCulture)); + return text; } - if (@event.Payload.AppId != null) - { - sb.Replace(AppIdPlaceholder, @event.Payload.AppId.Id.ToString()); - sb.Replace(AppNamePlaceholder, @event.Payload.AppId.Name); - } + var current = text.AsSpan(); + + var sb = new StringBuilder(); - if (@event.Payload is SchemaEvent schemaEvent && schemaEvent.SchemaId != null) + var cp2 = new ReadOnlySpan(ContentPlaceholderStartNew); + var cp1 = new ReadOnlySpan(ContentPlaceholderStartOld); + + for (var i = 0; i < current.Length; i++) { - sb.Replace(SchemaIdPlaceholder, schemaEvent.SchemaId.Id.ToString()); - sb.Replace(SchemaNamePlaceholder, schemaEvent.SchemaId.Name); - } + var c = current[i]; + + if (c == '$') + { + sb.Append(current.Slice(0, i).ToString()); - FormatContentAction(@event, sb); + current = current.Slice(i); - var result = sb.ToString(); + var test = current.Slice(1); + var tested = false; - if (@event.Payload is ContentCreated contentCreated && contentCreated.Data != null) - { - result = ReplaceData(contentCreated.Data, result); + for (var j = 0; j < patterns.Count; j++) + { + var (pattern, replacer) = patterns[j]; + + if (test.StartsWith(pattern, StringComparison.OrdinalIgnoreCase)) + { + sb.Append(replacer(@event)); + + current = current.Slice(pattern.Length + 1); + i = 0; + + tested = true; + break; + } + } + + if (!tested && (test.StartsWith(cp1, StringComparison.OrdinalIgnoreCase) || test.StartsWith(cp2, StringComparison.OrdinalIgnoreCase))) + { + var currentString = test.ToString(); + + var match = ContentDataPlaceholderOld.Match(currentString); + + if (!match.Success) + { + match = ContentDataPlaceholderNew.Match(currentString); + } + + if (match.Success) + { + if (@event is EnrichedContentEvent contentEvent) + { + sb.Append(CalculateData(contentEvent.Data, match)); + } + else + { + sb.Append(Undefined); + } + + current = current.Slice(match.Length + 1); + i = 0; + } + } + } } - if (@event.Payload is ContentUpdated contentUpdated && contentUpdated.Data != null) + sb.Append(current.ToString()); + + return sb.ToString(); + } + + private static string TimestampDate(EnrichedEvent @event) + { + return @event.Timestamp.ToDateTimeUtc().ToString("yyy-MM-dd", CultureInfo.InvariantCulture); + } + + private static string TimestampTime(EnrichedEvent @event) + { + return @event.Timestamp.ToDateTimeUtc().ToString("yyy-MM-dd-hh-mm-ss", CultureInfo.InvariantCulture); + } + + private static string AppId(EnrichedEvent @event) + { + return @event.AppId.Id.ToString(); + } + + private static string AppName(EnrichedEvent @event) + { + return @event.AppId.Name; + } + + private static string SchemaId(EnrichedEvent @event) + { + if (@event is EnrichedSchemaEvent schemaEvent) { - result = ReplaceData(contentUpdated.Data, result); + return schemaEvent.SchemaId.Id.ToString(); } - return result; + return Undefined; } - private static void FormatContentAction(Envelope @event, StringBuilder sb) + private static string SchemaName(EnrichedEvent @event) { - switch (@event.Payload) + if (@event is EnrichedSchemaEvent schemaEvent) { - case ContentCreated contentCreated: - sb.Replace(ContentActionPlaceholder, "created"); - break; - - case ContentUpdated contentUpdated: - sb.Replace(ContentActionPlaceholder, "updated"); - break; + return schemaEvent.SchemaId.Name; + } - case ContentStatusChanged contentStatusChanged: - sb.Replace(ContentActionPlaceholder, $"set to {contentStatusChanged.Status.ToString().ToLowerInvariant()}"); - break; + return Undefined; + } - case ContentDeleted contentDeleted: - sb.Replace(ContentActionPlaceholder, "deleted"); - break; + private static string ContentAction(EnrichedEvent @event) + { + if (@event is EnrichedContentEvent contentEvent) + { + return contentEvent.Type.ToString().ToLowerInvariant(); } + + return Undefined; } - private static string ReplaceData(NamedContentData data, string text) + private string ContentUrl(EnrichedEvent @event) { - return ContentDataPlaceholder.Replace(text, match => + if (@event is EnrichedContentEvent contentEvent) { - var captures = match.Groups[2].Captures; + return urlGenerator.GenerateContentUIUrl(contentEvent.AppId, contentEvent.SchemaId, contentEvent.Id); + } - var path = new string[captures.Count]; + return Undefined; + } - for (var i = 0; i < path.Length; i++) + private static string UserName(EnrichedEvent @event) + { + if (@event.Actor != null) + { + if (@event.Actor.Type.Equals(RefTokenType.Client, StringComparison.OrdinalIgnoreCase)) { - path[i] = captures[i].Value; + return @event.Actor.ToString(); } - if (!data.TryGetValue(path[0], out var field)) + if (@event.User != null) { - return Undefined; + return @event.User.DisplayName(); } + } + + return Undefined; + } - if (!field.TryGetValue(path[1], out var value)) + private static string UserEmail(EnrichedEvent @event) + { + if (@event.Actor != null) + { + if (@event.Actor.Type.Equals(RefTokenType.Client, StringComparison.OrdinalIgnoreCase)) { - return Undefined; + return @event.Actor.ToString(); } - for (var j = 2; j < path.Length; j++) + if (@event.User != null) { - if (value is JObject obj && obj.TryGetValue(path[j], out value)) - { - continue; - } - if (value is JArray arr && int.TryParse(path[j], out var idx) && idx >= 0 && idx < arr.Count) - { - value = arr[idx]; - } - else - { - return Undefined; - } + return @event.User.Email; } + } + + return Undefined; + } + + private static string CalculateData(NamedContentData data, Match match) + { + var captures = match.Groups[2].Captures; + + var path = new string[captures.Count]; + + for (var i = 0; i < path.Length; i++) + { + path[i] = captures[i].Value; + } + + if (!data.TryGetValue(path[0], out var field)) + { + return Undefined; + } + + if (!field.TryGetValue(path[1], out var value)) + { + return Undefined; + } - if (value == null || value.Type == JTokenType.Null || value.Type == JTokenType.Undefined) + for (var j = 2; j < path.Length; j++) + { + if (value is JObject obj && obj.TryGetValue(path[j], out value)) { - return Undefined; + continue; } - if (value is JValue jValue && jValue != null) + if (value is JArray arr && int.TryParse(path[j], out var idx) && idx >= 0 && idx < arr.Count) + { + value = arr[idx]; + } + else { - return jValue.Value.ToString(); + return Undefined; } + } + + if (value == null || value.Type == JTokenType.Null || value.Type == JTokenType.Undefined) + { + return Undefined; + } + + if (value is JValue jValue) + { + return jValue.Value.ToString(); + } - return value?.ToString(Formatting.Indented) ?? Undefined; - }); + return value.ToString(Formatting.Indented) ?? Undefined; } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs index ecac1cf7a..211c5d380 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs @@ -11,6 +11,7 @@ using System.Diagnostics; using System.Linq; using System.Text; using System.Threading.Tasks; +using Newtonsoft.Json.Linq; using NodaTime; using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Events; @@ -21,21 +22,23 @@ namespace Squidex.Domain.Apps.Core.HandleRules { public class RuleService { - private const string ContentPrefix = "Content"; private readonly Dictionary ruleActionHandlers; private readonly Dictionary ruleTriggerHandlers; private readonly TypeNameRegistry typeNameRegistry; + private readonly IEventEnricher eventEnricher; private readonly IClock clock; public RuleService( IEnumerable ruleTriggerHandlers, IEnumerable ruleActionHandlers, + IEventEnricher eventEnricher, IClock clock, TypeNameRegistry typeNameRegistry) { Guard.NotNull(ruleTriggerHandlers, nameof(ruleTriggerHandlers)); Guard.NotNull(ruleActionHandlers, nameof(ruleActionHandlers)); Guard.NotNull(typeNameRegistry, nameof(typeNameRegistry)); + Guard.NotNull(eventEnricher, nameof(eventEnricher)); Guard.NotNull(clock, nameof(clock)); this.typeNameRegistry = typeNameRegistry; @@ -43,10 +46,12 @@ namespace Squidex.Domain.Apps.Core.HandleRules this.ruleTriggerHandlers = ruleTriggerHandlers.ToDictionary(x => x.TriggerType); this.ruleActionHandlers = ruleActionHandlers.ToDictionary(x => x.ActionType); + this.eventEnricher = eventEnricher; + this.clock = clock; } - public virtual RuleJob CreateJob(Rule rule, Envelope @event) + public virtual async Task CreateJobAsync(Rule rule, Envelope @event) { Guard.NotNull(rule, nameof(rule)); Guard.NotNull(@event, nameof(@event)); @@ -75,45 +80,42 @@ namespace Squidex.Domain.Apps.Core.HandleRules return null; } - var eventName = CreateEventName(appEvent); - var now = clock.GetCurrentInstant(); - var actionName = typeNameRegistry.GetName(actionType); - var actionData = actionHandler.CreateJob(appEventEnvelope, eventName, rule.Action); - var eventTime = @event.Headers.Contains(CommonHeaders.Timestamp) ? @event.Headers.Timestamp() : now; - var aggregateId = - @event.Headers.Contains(CommonHeaders.AggregateId) ? - @event.Headers.AggregateId() : - Guid.NewGuid(); + var expires = eventTime.Plus(Constants.ExpirationTime); + + if (expires < now) + { + return null; + } + + var enrichedEvent = await eventEnricher.EnrichAsync(appEventEnvelope); + + var actionName = typeNameRegistry.GetName(actionType); + var actionData = await actionHandler.CreateJobAsync(enrichedEvent, rule.Action); var job = new RuleJob { JobId = Guid.NewGuid(), ActionName = actionName, ActionData = actionData.Data, - AggregateId = aggregateId, + AggregateId = enrichedEvent.AggregateId, AppId = appEvent.AppId.Id, Created = now, - EventName = eventName, - Expires = eventTime.Plus(Constants.ExpirationTime), + EventName = enrichedEvent.Name, + Expires = expires, Description = actionData.Description }; - if (job.Expires < now) - { - return null; - } - return job; } - public virtual async Task<(string Dump, RuleResult Result, TimeSpan Elapsed)> InvokeAsync(string actionName, RuleJobData job) + public virtual async Task<(string Dump, RuleResult Result, TimeSpan Elapsed)> InvokeAsync(string actionName, JObject job) { try { @@ -151,22 +153,5 @@ namespace Squidex.Domain.Apps.Core.HandleRules return (ex.ToString(), RuleResult.Failed, TimeSpan.Zero); } } - - private string CreateEventName(AppEvent appEvent) - { - var eventName = typeNameRegistry.GetName(appEvent.GetType()); - - if (appEvent is SchemaEvent schemaEvent) - { - if (eventName.StartsWith(ContentPrefix, StringComparison.Ordinal)) - { - eventName = eventName.Substring(ContentPrefix.Length); - } - - return $"{schemaEvent.SchemaId.Name.ToPascalCase()}{eventName}"; - } - - return eventName; - } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Triggers/AssetChangedTriggerHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Triggers/AssetChangedTriggerHandler.cs index 22555b6e2..7df652e50 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Triggers/AssetChangedTriggerHandler.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Triggers/AssetChangedTriggerHandler.cs @@ -22,10 +22,10 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Triggers private static bool MatchsType(AssetChangedTrigger trigger, AssetEvent @event) { return - (trigger.SendCreate && @event is AssetCreated) || - (trigger.SendUpdate && @event is AssetUpdated) || - (trigger.SendDelete && @event is AssetDeleted) || - (trigger.SendRename && @event is AssetRenamed); + trigger.SendCreate && @event is AssetCreated || + trigger.SendUpdate && @event is AssetUpdated || + trigger.SendDelete && @event is AssetDeleted || + trigger.SendRename && @event is AssetRenamed; } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Triggers/ContentChangedTriggerHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Triggers/ContentChangedTriggerHandler.cs index 13ca5f459..f03100f98 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Triggers/ContentChangedTriggerHandler.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Triggers/ContentChangedTriggerHandler.cs @@ -17,7 +17,11 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Triggers { protected override bool Triggers(Envelope @event, ContentChangedTrigger trigger) { - if (trigger.HandleAll) + if (trigger.HandleAll && + @event.Payload is ContentEvent && + !(@event.Payload is ContentChangesPublished) && + !(@event.Payload is ContentChangesDiscarded) && + !(@event.Payload is ContentUpdateProposed)) { return true; } @@ -44,10 +48,48 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Triggers private static bool MatchsType(ContentChangedTriggerSchema schema, SchemaEvent @event) { return - (schema.SendCreate && @event is ContentCreated) || - (schema.SendUpdate && @event is ContentUpdated) || - (schema.SendDelete && @event is ContentDeleted) || - (schema.SendPublish && @event is ContentStatusChanged statusChanged && statusChanged.Status == Status.Published); + IsArchived(schema, @event) || + IsCreate(schema, @event) || + IsDelete(schema, @event) || + IsPublished(schema, @event) || + IsRestored(schema, @event) || + IsUpdate(schema, @event) || + IsUnpublished(schema, @event); + } + + private static bool IsPublished(ContentChangedTriggerSchema schema, SchemaEvent @event) + { + return schema.SendPublish && @event is ContentStatusChanged statusChanged && statusChanged.Change == StatusChange.Published; + } + + private static bool IsRestored(ContentChangedTriggerSchema schema, SchemaEvent @event) + { + return schema.SendRestore && @event is ContentStatusChanged statusChanged && statusChanged.Change == StatusChange.Restored; + } + + private static bool IsArchived(ContentChangedTriggerSchema schema, SchemaEvent @event) + { + return schema.SendArchived && @event is ContentStatusChanged statusChanged && statusChanged.Change == StatusChange.Archived; + } + + private static bool IsUnpublished(ContentChangedTriggerSchema schema, SchemaEvent @event) + { + return schema.SendUnpublish && @event is ContentStatusChanged statusChanged && statusChanged.Change == StatusChange.Unpublished; + } + + private static bool IsCreate(ContentChangedTriggerSchema schema, SchemaEvent @event) + { + return schema.SendCreate && @event is ContentCreated; + } + + private static bool IsUpdate(ContentChangedTriggerSchema schema, SchemaEvent @event) + { + return schema.SendUpdate && @event is ContentUpdated || schema.SendUpdate && @event is ContentChangesPublished; + } + + private static bool IsDelete(ContentChangedTriggerSchema schema, SchemaEvent @event) + { + return schema.SendDelete && @event is ContentDeleted; } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataObject.cs b/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataObject.cs index 79b1e14c4..6a72f9508 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataObject.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataObject.cs @@ -95,14 +95,14 @@ namespace Squidex.Domain.Apps.Core.Scripting.ContentWrapper { EnsurePropertiesInitialized(); - fieldProperties.GetOrAdd(propertyName, x => new ContentDataProperty(this)).Value = value; + fieldProperties.GetOrAdd(propertyName, this, (k, c) => new ContentDataProperty(c)).Value = value; } public override PropertyDescriptor GetOwnProperty(string propertyName) { EnsurePropertiesInitialized(); - return fieldProperties.GetOrAdd(propertyName, x => new ContentDataProperty(this, new ContentFieldObject(this, new ContentFieldData(), false))); + return fieldProperties.GetOrAdd(propertyName, this, (k, c) => new ContentDataProperty(c, new ContentFieldObject(c, new ContentFieldData(), false))); } public override IEnumerable> GetOwnProperties() diff --git a/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/JsonMapper.cs b/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/JsonMapper.cs index ea3a19385..6ea3cd584 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/JsonMapper.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/JsonMapper.cs @@ -43,18 +43,18 @@ namespace Squidex.Domain.Apps.Core.Scripting.ContentWrapper case JTokenType.Object: return FromObject(value, engine); case JTokenType.Array: - { - var arr = (JArray)value; + { + var arr = (JArray)value; - var target = new JsValue[arr.Count]; + var target = new JsValue[arr.Count]; - for (var i = 0; i < arr.Count; i++) - { - target[i] = Map(arr[i], engine); - } + for (var i = 0; i < arr.Count; i++) + { + target[i] = Map(arr[i], engine); + } - return engine.Array.Construct(target); - } + return engine.Array.Construct(target); + } } throw new ArgumentException("Invalid json type.", nameof(value)); diff --git a/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs b/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs index 57e135981..890b9c26c 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs @@ -7,9 +7,11 @@ using System; using Jint; +using Jint.Native; using Jint.Native.Object; using Jint.Parser; using Jint.Runtime; +using Jint.Runtime.Interop; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Scripting.ContentWrapper; using Squidex.Infrastructure; @@ -150,11 +152,32 @@ namespace Squidex.Domain.Apps.Core.Scripting } engine.SetValue("ctx", contextInstance); - engine.SetValue("slugify", new Func(x => x.Slugify())); + + engine.SetValue("slugify", new ClrFunctionInstance(engine, Slugify)); return engine; } + private static JsValue Slugify(JsValue thisObject, JsValue[] arguments) + { + try + { + var stringInput = TypeConverter.ToString(arguments.At(0)); + var single = false; + + if (arguments.Length > 1) + { + single = TypeConverter.ToBoolean(arguments.At(1)); + } + + return stringInput.Slugify(null, single); + } + catch + { + return JsValue.Undefined; + } + } + private static void EnableDisallow(Engine engine) { engine.SetValue("disallow", new Action(message => diff --git a/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj b/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj index 4c836c4ee..f366ea646 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj +++ b/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj @@ -1,4 +1,4 @@ - + netstandard2.0 Squidex.Domain.Apps.Core @@ -11,20 +11,17 @@ + - - - - - - - - - - - + + + + + + + ..\..\Squidex.ruleset diff --git a/src/Squidex.Domain.Apps.Core.Operations/Tags/ITagService.cs b/src/Squidex.Domain.Apps.Core.Operations/Tags/ITagService.cs new file mode 100644 index 000000000..f0fc88a3a --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/Tags/ITagService.cs @@ -0,0 +1,30 @@ +// ========================================================================== +// 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; + +namespace Squidex.Domain.Apps.Core.Tags +{ + public interface ITagService + { + Task> GetTagIdsAsync(Guid appId, string group, HashSet names); + + Task> NormalizeTagsAsync(Guid appId, string group, HashSet names, HashSet ids); + + Task> DenormalizeTagsAsync(Guid appId, string group, HashSet ids); + + Task> GetTagsAsync(Guid appId, string group); + + Task GetExportableTagsAsync(Guid appId, string group); + + Task RebuildTagsAsync(Guid appId, string group, TagSet tags); + + Task ClearAsync(Guid appId, string group); + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/Tags/Tag.cs b/src/Squidex.Domain.Apps.Core.Operations/Tags/Tag.cs new file mode 100644 index 000000000..1bff58c70 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/Tags/Tag.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Core.Tags +{ + public sealed class Tag + { + public string Name { get; set; } + + public int Count { get; set; } = 1; + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/Tags/TagGroups.cs b/src/Squidex.Domain.Apps.Core.Operations/Tags/TagGroups.cs new file mode 100644 index 000000000..fd69b313b --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/Tags/TagGroups.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; + +namespace Squidex.Domain.Apps.Core.Tags +{ + public static class TagGroups + { + public const string Assets = "Assets"; + + public static string Schemas(Guid schemaId) + { + return $"Schemas_{schemaId}"; + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/Tags/TagNormalizer.cs b/src/Squidex.Domain.Apps.Core.Operations/Tags/TagNormalizer.cs new file mode 100644 index 000000000..ed9cd3f05 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/Tags/TagNormalizer.cs @@ -0,0 +1,152 @@ +// ========================================================================== +// 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 Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.Tags +{ + public static class TagNormalizer + { + public static async Task NormalizeAsync(this ITagService tagService, Guid appId, Guid schemaId, Schema schema, NamedContentData newData, NamedContentData oldData) + { + Guard.NotNull(tagService, nameof(tagService)); + Guard.NotNull(schema, nameof(schema)); + Guard.NotNull(newData, nameof(newData)); + + var newValues = new HashSet(); + var newArrays = new List(); + + var oldValues = new HashSet(); + var oldArrays = new List(); + + GetValues(schema, newValues, newArrays, newData); + + if (oldData != null) + { + GetValues(schema, oldValues, oldArrays, oldData); + } + + if (newValues.Count > 0) + { + var normalized = await tagService.NormalizeTagsAsync(appId, TagGroups.Schemas(schemaId), newValues, oldValues); + + foreach (var array in newArrays) + { + for (var i = 0; i < array.Count; i++) + { + if (normalized.TryGetValue(array[i].ToString(), out var result)) + { + array[i] = result; + } + } + } + } + } + + public static async Task DenormalizeAsync(this ITagService tagService, Guid appId, Guid schemaId, Schema schema, params NamedContentData[] datas) + { + Guard.NotNull(tagService, nameof(tagService)); + Guard.NotNull(schema, nameof(schema)); + + var tagsValues = new HashSet(); + var tagsArrays = new List(); + + GetValues(schema, tagsValues, tagsArrays, datas); + + if (tagsValues.Count > 0) + { + var denormalized = await tagService.DenormalizeTagsAsync(appId, TagGroups.Schemas(schemaId), tagsValues); + + foreach (var array in tagsArrays) + { + for (var i = 0; i < array.Count; i++) + { + if (denormalized.TryGetValue(array[i].ToString(), out var result)) + { + array[i] = result; + } + } + } + } + } + + private static void GetValues(Schema schema, HashSet values, List arrays, params NamedContentData[] datas) + { + foreach (var field in schema.Fields) + { + if (field is IField tags && tags.Properties.Normalization == TagsFieldNormalization.Schema) + { + foreach (var data in datas) + { + if (data.TryGetValue(field.Name, out var fieldData)) + { + foreach (var partition in fieldData) + { + ExtractTags(partition.Value, values, arrays); + } + } + } + } + else if (field is IArrayField arrayField) + { + foreach (var nestedField in arrayField.Fields) + { + if (nestedField is IField nestedTags && nestedTags.Properties.Normalization == TagsFieldNormalization.Schema) + { + foreach (var data in datas) + { + if (data.TryGetValue(field.Name, out var fieldData)) + { + foreach (var partition in fieldData) + { + if (partition.Value is JArray jArray) + { + foreach (var value in jArray) + { + if (value.Type == JTokenType.Object) + { + var nestedObject = (JObject)value; + + if (nestedObject.TryGetValue(nestedField.Name, out var nestedValue)) + { + ExtractTags(nestedValue, values, arrays); + } + } + } + } + } + } + } + } + } + } + } + } + + private static void ExtractTags(JToken token, ISet values, ICollection arrays) + { + if (token is JArray jArray) + { + foreach (var value in jArray) + { + if (value.Type == JTokenType.String) + { + values.Add(value.ToString()); + } + } + + arrays.Add(jArray); + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/Tags/TagSet.cs b/src/Squidex.Domain.Apps.Core.Operations/Tags/TagSet.cs new file mode 100644 index 000000000..530c28b00 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/Tags/TagSet.cs @@ -0,0 +1,15 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; + +namespace Squidex.Domain.Apps.Core.Tags +{ + public sealed class TagSet : Dictionary + { + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs index 5c0b24b81..38517df83 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs @@ -7,18 +7,22 @@ using System.Collections.Concurrent; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Newtonsoft.Json.Linq; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Core.ValidateContent.Validators; using Squidex.Infrastructure; -#pragma warning disable 168 +#pragma warning disable SA1028, IDE0004 // Code must not contain trailing whitespace namespace Squidex.Domain.Apps.Core.ValidateContent { public sealed class ContentValidator { + private static readonly ContentFieldData DefaultFieldData = new ContentFieldData(); + private static readonly JToken DefaultValue = JValue.CreateNull(); private readonly Schema schema; private readonly PartitionResolver partitionResolver; private readonly ValidationContext context; @@ -39,103 +43,60 @@ namespace Squidex.Domain.Apps.Core.ValidateContent this.partitionResolver = partitionResolver; } - public Task ValidatePartialAsync(NamedContentData data) + private void AddError(IEnumerable path, string message) { - Guard.NotNull(data, nameof(data)); - - var tasks = new List(); - - foreach (var fieldData in data) - { - var fieldName = fieldData.Key; - - if (!schema.FieldsByName.TryGetValue(fieldData.Key, out var field)) - { - errors.AddError(" is not a known field.", fieldName); - } - else - { - tasks.Add(ValidateFieldPartialAsync(field, fieldData.Value)); - } - } + var pathString = path.ToPathString(); - return Task.WhenAll(tasks); + errors.Add(new ValidationError($"{pathString}: {message}", pathString)); } - private Task ValidateFieldPartialAsync(Field field, ContentFieldData fieldData) + public Task ValidatePartialAsync(NamedContentData data) { - var partitioning = field.Partitioning; - var partition = partitionResolver(partitioning); - - var tasks = new List(); + Guard.NotNull(data, nameof(data)); - foreach (var partitionValues in fieldData) - { - if (partition.TryGetItem(partitionValues.Key, out var item)) - { - tasks.Add(field.ValidateAsync(partitionValues.Value, context.Optional(item.IsOptional), m => errors.AddError(m, field, item))); - } - else - { - errors.AddError($" has an unsupported {partitioning.Key} value '{partitionValues.Key}'.", field); - } - } + var validator = CreateSchemaValidator(true); - return Task.WhenAll(tasks); + return validator.ValidateAsync(data, context, AddError); } public Task ValidateAsync(NamedContentData data) { Guard.NotNull(data, nameof(data)); - ValidateUnknownFields(data); - - var tasks = new List(); - - foreach (var field in schema.FieldsByName.Values) - { - var fieldData = data.GetOrCreate(field.Name, k => new ContentFieldData()); - - tasks.Add(ValidateFieldAsync(field, fieldData)); - } + var validator = CreateSchemaValidator(false); - return Task.WhenAll(tasks); + return validator.ValidateAsync(data, context, AddError); } - private void ValidateUnknownFields(NamedContentData data) + private IValidator CreateSchemaValidator(bool isPartial) { - foreach (var fieldData in data) + var fieldsValidators = new Dictionary(); + + foreach (var field in schema.FieldsByName) { - if (!schema.FieldsByName.ContainsKey(fieldData.Key)) - { - errors.AddError(" is not a known field.", fieldData.Key); - } + fieldsValidators[field.Key] = (!field.Value.RawProperties.IsRequired, CreateFieldValidator(field.Value, isPartial)); } + + return new ObjectValidator(fieldsValidators, isPartial, "field", DefaultFieldData); } - private Task ValidateFieldAsync(Field field, ContentFieldData fieldData) + private IValidator CreateFieldValidator(IRootField field, bool isPartial) { - var partitioning = field.Partitioning; - var partition = partitionResolver(partitioning); + var partitioning = partitionResolver(field.Partitioning); - var tasks = new List(); + var fieldValidator = new FieldValidator(ValidatorsFactory.CreateValidators(field).ToArray(), field); + var fieldsValidators = new Dictionary(); - foreach (var partitionValues in fieldData) + foreach (var partition in partitioning) { - if (!partition.TryGetItem(partitionValues.Key, out var _)) - { - errors.AddError($" has an unsupported {partitioning.Key} value '{partitionValues.Key}'.", field); - } + fieldsValidators[partition.Key] = (partition.IsOptional, fieldValidator); } - foreach (var item in partition) - { - var value = fieldData.GetOrCreate(item.Key, k => JValue.CreateNull()); + var isLanguage = field.Partitioning.Equals(Partitioning.Language); - tasks.Add(field.ValidateAsync(value, context.Optional(item.IsOptional), m => errors.AddError(m, field, item))); - } + var type = isLanguage ? "language" : "invariant value"; - return Task.WhenAll(tasks); + return new ObjectValidator(fieldsValidators, isPartial, type, DefaultValue); } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/FieldExtensions.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/FieldExtensions.cs deleted file mode 100644 index 7106971e1..000000000 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/FieldExtensions.cs +++ /dev/null @@ -1,57 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Concurrent; -using System.Threading.Tasks; -using Newtonsoft.Json.Linq; -using Squidex.Domain.Apps.Core.Schemas; -using Squidex.Infrastructure; -using Squidex.Infrastructure.Json; - -namespace Squidex.Domain.Apps.Core.ValidateContent -{ - public static class FieldExtensions - { - public static void AddError(this ConcurrentBag errors, string message, Field field, IFieldPartitionItem partitionItem = null) - { - AddError(errors, message, !string.IsNullOrWhiteSpace(field.RawProperties.Label) ? field.RawProperties.Label : field.Name, field.Name, partitionItem); - } - - public static void AddError(this ConcurrentBag errors, string message, string fieldName, IFieldPartitionItem partitionItem = null) - { - AddError(errors, message, fieldName, fieldName, partitionItem); - } - - public static void AddError(this ConcurrentBag errors, string message, string displayName, string fieldName, IFieldPartitionItem partitionItem = null) - { - if (partitionItem != null && partitionItem != InvariantPartitioning.Instance.Master) - { - displayName += $" ({partitionItem.Key})"; - } - - errors.Add(new ValidationError(message.Replace("", displayName), fieldName)); - } - - public static async Task ValidateAsync(this Field field, JToken value, ValidationContext context, Action addError) - { - try - { - var typedValue = value.IsNull() ? null : JsonValueConverter.ConvertValue(field, value); - - foreach (var validator in ValidatorsFactory.CreateValidators(field)) - { - await validator.ValidateAsync(typedValue, context, addError); - } - } - catch - { - addError(" is not a valid value."); - } - } - } -} diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueConverter.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueConverter.cs index 9095331b3..a43f8a095 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueConverter.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueConverter.cs @@ -16,33 +16,38 @@ namespace Squidex.Domain.Apps.Core.ValidateContent { public sealed class JsonValueConverter : IFieldVisitor { - public JToken Value { get; } + private readonly JToken value; private JsonValueConverter(JToken value) { - this.Value = value; + this.value = value; } - public static object ConvertValue(Field field, JToken json) + public static object ConvertValue(IField field, JToken json) { return field.Accept(new JsonValueConverter(json)); } - public object Visit(AssetsField field) + public object Visit(IArrayField field) { - return Value.ToObject>(); + return value.ToObject>(); } - public object Visit(BooleanField field) + public object Visit(IField field) { - return (bool?)Value; + return value.ToObject>(); } - public object Visit(DateTimeField field) + public object Visit(IField field) { - if (Value.Type == JTokenType.String) + return (bool?)value; + } + + public object Visit(IField field) + { + if (value.Type == JTokenType.String) { - var parseResult = InstantPattern.General.Parse(Value.ToString()); + var parseResult = InstantPattern.General.Parse(value.ToString()); if (!parseResult.Success) { @@ -55,9 +60,9 @@ namespace Squidex.Domain.Apps.Core.ValidateContent throw new InvalidCastException("Invalid json type, expected string."); } - public object Visit(GeolocationField field) + public object Visit(IField field) { - var geolocation = (JObject)Value; + var geolocation = (JObject)value; foreach (var property in geolocation.Properties()) { @@ -81,32 +86,32 @@ namespace Squidex.Domain.Apps.Core.ValidateContent throw new InvalidCastException("Longitude must be between -180 and 180."); } - return Value; + return value; } - public object Visit(JsonField field) + public object Visit(IField field) { - return Value; + return value; } - public object Visit(NumberField field) + public object Visit(IField field) { - return (double?)Value; + return (double?)value; } - public object Visit(ReferencesField field) + public object Visit(IField field) { - return Value.ToObject>(); + return value.ToObject>(); } - public object Visit(StringField field) + public object Visit(IField field) { - return Value.ToString(); + return value.ToString(); } - public object Visit(TagsField field) + public object Visit(IField field) { - return Value.ToObject>(); + return value.ToObject>(); } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ObjectPath.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ObjectPath.cs new file mode 100644 index 000000000..3b1c216cb --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ObjectPath.cs @@ -0,0 +1,52 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Squidex.Domain.Apps.Core.ValidateContent +{ + public static class ObjectPath + { + public static string ToPathString(this IEnumerable path) + { + var sb = new StringBuilder(); + + var index = 0; + foreach (var property in path) + { + if (index == 0) + { + sb.Append(property); + } + else if (index == 1) + { + if (!property.Equals(InvariantPartitioning.Instance.Master.Key, StringComparison.OrdinalIgnoreCase)) + { + sb.Append("("); + sb.Append(property); + sb.Append(")"); + } + } + else + { + if (property[0] != '[') + { + sb.Append("."); + } + + sb.Append(property); + } + + index++; + } + + return sb.ToString(); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ValidationContext.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ValidationContext.cs index 4a57b5fc8..5db995c34 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ValidationContext.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ValidationContext.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Threading.Tasks; using Squidex.Infrastructure; @@ -16,24 +17,33 @@ namespace Squidex.Domain.Apps.Core.ValidateContent { private readonly Func, Guid, Task>> checkContent; private readonly Func, Task>> checkAsset; + private readonly ImmutableQueue propertyPath; + + public ImmutableQueue Path + { + get { return propertyPath; } + } public bool IsOptional { get; } public ValidationContext( Func, Guid, Task>> checkContent, Func, Task>> checkAsset) - : this(checkContent, checkAsset, false) + : this(checkContent, checkAsset, ImmutableQueue.Empty, false) { } private ValidationContext( Func, Guid, Task>> checkContent, Func, Task>> checkAsset, + ImmutableQueue propertyPath, bool isOptional) { Guard.NotNull(checkAsset, nameof(checkAsset)); Guard.NotNull(checkContent, nameof(checkAsset)); + this.propertyPath = propertyPath; + this.checkContent = checkContent; this.checkAsset = checkAsset; @@ -42,7 +52,12 @@ namespace Squidex.Domain.Apps.Core.ValidateContent public ValidationContext Optional(bool isOptional) { - return isOptional == IsOptional ? this : new ValidationContext(checkContent, checkAsset, isOptional); + return isOptional == IsOptional ? this : new ValidationContext(checkContent, checkAsset, propertyPath, isOptional); + } + + public ValidationContext Nested(string property) + { + return new ValidationContext(checkContent, checkAsset, propertyPath.Enqueue(property), IsOptional); } public Task> GetInvalidContentIdsAsync(IEnumerable contentIds, Guid schemaId) diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/AllowedValuesValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/AllowedValuesValidator.cs index 44aba8c60..9b1d6129d 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/AllowedValuesValidator.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/AllowedValuesValidator.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Linq; using System.Threading.Tasks; using Squidex.Infrastructure; @@ -24,7 +23,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators this.allowedValues = allowedValues; } - public Task ValidateAsync(object value, ValidationContext context, Action addError) + public Task ValidateAsync(object value, ValidationContext context, AddError addError) { if (value == null) { @@ -35,7 +34,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators if (!allowedValues.Contains(typedValue)) { - addError(" is not an allowed value."); + addError(context.Path, "Not an allowed value."); } return TaskHelper.Done; diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/AssetsValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/AssetsValidator.cs index 2c6db10d7..f1a87c283 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/AssetsValidator.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/AssetsValidator.cs @@ -23,52 +23,49 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators this.properties = properties; } - public async Task ValidateAsync(object value, ValidationContext context, Action addError) + public async Task ValidateAsync(object value, ValidationContext context, AddError addError) { - if (value is ICollection assetIds) + if (value is ICollection assetIds && assetIds.Count > 0) { var assets = await context.GetAssetInfosAsync(assetIds); - var i = 0; + var index = 0; foreach (var assetId in assetIds) { - i++; + index++; - var asset = assets.FirstOrDefault(x => x.AssetId == assetId); + var path = context.Path.Enqueue($"[{index}]"); - void Error(string message) - { - addError($" has invalid asset #{i}: {message}"); - } + var asset = assets.FirstOrDefault(x => x.AssetId == assetId); if (asset == null) { - Error($"Id '{assetId}' not found."); + addError(path, $"Id '{assetId}' not found."); continue; } if (properties.MinSize.HasValue && asset.FileSize < properties.MinSize) { - Error($"'{asset.FileSize.ToReadableSize()}' less than minimum of '{properties.MinSize.Value.ToReadableSize()}'."); + addError(path, $"'{asset.FileSize.ToReadableSize()}' less than minimum of '{properties.MinSize.Value.ToReadableSize()}'."); } if (properties.MaxSize.HasValue && asset.FileSize > properties.MaxSize) { - Error($"'{asset.FileSize.ToReadableSize()}' greater than maximum of '{properties.MaxSize.Value.ToReadableSize()}'."); + addError(path, $"'{asset.FileSize.ToReadableSize()}' greater than maximum of '{properties.MaxSize.Value.ToReadableSize()}'."); } if (properties.AllowedExtensions != null && properties.AllowedExtensions.Count > 0 && !properties.AllowedExtensions.Any(x => asset.FileName.EndsWith("." + x, StringComparison.OrdinalIgnoreCase))) { - Error("Invalid file extension."); + addError(path, "Invalid file extension."); } if (!asset.IsImage) { if (properties.MustBeImage) { - Error("Not an image."); + addError(path, "Not an image."); } continue; @@ -84,22 +81,22 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators if (properties.MinWidth.HasValue && w < properties.MinWidth) { - Error($"Width '{w}px' less than minimum of '{properties.MinWidth}px'."); + addError(path, $"Width '{w}px' less than minimum of '{properties.MinWidth}px'."); } if (properties.MaxWidth.HasValue && w > properties.MaxWidth) { - Error($"Width '{w}px' greater than maximum of '{properties.MaxWidth}px'."); + addError(path, $"Width '{w}px' greater than maximum of '{properties.MaxWidth}px'."); } if (properties.MinHeight.HasValue && h < properties.MinHeight) { - Error($"Height '{h}px' less than minimum of '{properties.MinHeight}px'."); + addError(path, $"Height '{h}px' less than minimum of '{properties.MinHeight}px'."); } if (properties.MaxHeight.HasValue && h > properties.MaxHeight) { - Error($"Height '{h}px' greater than maximum of '{properties.MaxHeight}px'."); + addError(path, $"Height '{h}px' greater than maximum of '{properties.MaxHeight}px'."); } if (properties.AspectHeight.HasValue && properties.AspectWidth.HasValue) @@ -108,7 +105,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators if (Math.Abs(expectedRatio - actualRatio) > double.Epsilon) { - Error($"Aspect ratio not '{properties.AspectWidth}:{properties.AspectHeight}'."); + addError(path, $"Aspect ratio not '{properties.AspectWidth}:{properties.AspectHeight}'."); } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/CollectionItemValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/CollectionItemValidator.cs index ea75cdd53..8e8efdd46 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/CollectionItemValidator.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/CollectionItemValidator.cs @@ -5,14 +5,14 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; +using System.Collections; using System.Collections.Generic; using System.Threading.Tasks; using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.ValidateContent.Validators { - public sealed class CollectionItemValidator : IValidator + public sealed class CollectionItemValidator : IValidator { private readonly IValidator[] itemValidators; @@ -24,23 +24,26 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators this.itemValidators = itemValidators; } - public async Task ValidateAsync(object value, ValidationContext context, Action addError) + public async Task ValidateAsync(object value, ValidationContext context, AddError addError) { - if (value is ICollection items) + if (value is ICollection items && items.Count > 0) { - var innerContext = context.Optional(false); - + var innerTasks = new List(); var index = 1; foreach (var item in items) { + var innerContext = context.Nested($"[{index}]"); + foreach (var itemValidator in itemValidators) { - await itemValidator.ValidateAsync(item, innerContext, e => addError(e.Replace("", $" item #{index}"))); + innerTasks.Add(itemValidator.ValidateAsync(item, innerContext, addError)); } index++; } + + await Task.WhenAll(innerTasks); } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/CollectionValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/CollectionValidator.cs index a1699e802..820afe308 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/CollectionValidator.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/CollectionValidator.cs @@ -5,14 +5,13 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; -using System.Collections.Generic; +using System.Collections; using System.Threading.Tasks; using Squidex.Infrastructure.Tasks; namespace Squidex.Domain.Apps.Core.ValidateContent.Validators { - public sealed class CollectionValidator : IValidator + public sealed class CollectionValidator : IValidator { private readonly bool isRequired; private readonly int? minItems; @@ -25,13 +24,13 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators this.maxItems = maxItems; } - public Task ValidateAsync(object value, ValidationContext context, Action addError) + public Task ValidateAsync(object value, ValidationContext context, AddError addError) { - if (!(value is ICollection items) || items.Count == 0) + if (!(value is ICollection items) || items.Count == 0) { if (isRequired && !context.IsOptional) { - addError(" is required."); + addError(context.Path, "Field is required."); } return TaskHelper.Done; @@ -39,12 +38,12 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators if (minItems.HasValue && items.Count < minItems.Value) { - addError($" must have at least {minItems} item(s)."); + addError(context.Path, $"Must have at least {minItems} item(s)."); } if (maxItems.HasValue && items.Count > maxItems.Value) { - addError($" must have not more than {maxItems} item(s)."); + addError(context.Path, $"Must have not more than {maxItems} item(s)."); } return TaskHelper.Done; diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/FieldValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/FieldValidator.cs new file mode 100644 index 000000000..97471857e --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/FieldValidator.cs @@ -0,0 +1,53 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Infrastructure.Json; + +namespace Squidex.Domain.Apps.Core.ValidateContent.Validators +{ + public sealed class FieldValidator : IValidator + { + private readonly IValidator[] validators; + private readonly IField field; + + public FieldValidator(IValidator[] validators, IField field) + { + this.validators = validators; + this.field = field; + } + + public async Task ValidateAsync(object value, ValidationContext context, AddError addError) + { + try + { + object typedValue = null; + + if (value is JToken jToken) + { + typedValue = jToken.IsNull() ? null : JsonValueConverter.ConvertValue(field, jToken); + } + + var tasks = new List(); + + foreach (var validator in validators) + { + tasks.Add(validator.ValidateAsync(typedValue, context, addError)); + } + + await Task.WhenAll(tasks); + } + catch + { + addError(context.Path, "Not a valid value."); + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/IValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/IValidator.cs index e0d7c49f8..47592700f 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/IValidator.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/IValidator.cs @@ -5,13 +5,15 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; +using System.Collections.Generic; using System.Threading.Tasks; namespace Squidex.Domain.Apps.Core.ValidateContent.Validators { + public delegate void AddError(IEnumerable path, string message); + public interface IValidator { - Task ValidateAsync(object value, ValidationContext context, Action addError); + Task ValidateAsync(object value, ValidationContext context, AddError addError); } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ObjectValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ObjectValidator.cs new file mode 100644 index 000000000..6dd8a9c28 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ObjectValidator.cs @@ -0,0 +1,68 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Squidex.Domain.Apps.Core.ValidateContent.Validators +{ + public sealed class ObjectValidator : IValidator + { + private readonly IDictionary schema; + private readonly bool isPartial; + private readonly string fieldType; + private readonly TValue fieldDefault; + + public ObjectValidator(IDictionary schema, bool isPartial, string fieldType, TValue fieldDefault) + { + this.schema = schema; + this.fieldDefault = fieldDefault; + this.fieldType = fieldType; + this.isPartial = isPartial; + } + + public async Task ValidateAsync(object value, ValidationContext context, AddError addError) + { + if (value is IDictionary values) + { + foreach (var fieldData in values) + { + var name = fieldData.Key; + + if (!schema.ContainsKey(name)) + { + addError(context.Path.Enqueue(name), $"Not a known {fieldType}."); + } + } + + var tasks = new List(); + + foreach (var field in schema) + { + var name = field.Key; + + if (!values.TryGetValue(name, out var fieldValue)) + { + if (isPartial) + { + continue; + } + + fieldValue = fieldDefault; + } + + var (isOptional, validator) = field.Value; + var fieldContext = context.Nested(name).Optional(isOptional); + + tasks.Add(validator.ValidateAsync(fieldValue, fieldContext, addError)); + } + + await Task.WhenAll(tasks); + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/PatternValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/PatternValidator.cs index ec799c0bc..c358c3bdf 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/PatternValidator.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/PatternValidator.cs @@ -25,7 +25,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators regex = new Regex("^" + pattern + "$", RegexOptions.None, Timeout); } - public Task ValidateAsync(object value, ValidationContext context, Action addError) + public Task ValidateAsync(object value, ValidationContext context, AddError addError) { if (value is string stringValue) { @@ -37,17 +37,17 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators { if (string.IsNullOrWhiteSpace(errorMessage)) { - addError(" is not valid."); + addError(context.Path, "Not valid."); } else { - addError(errorMessage); + addError(context.Path, errorMessage); } } } catch { - addError(" has a regex that is too slow."); + addError(context.Path, "Regex is too slow."); } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/RangeValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/RangeValidator.cs index 47507c696..a2b617477 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/RangeValidator.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/RangeValidator.cs @@ -27,7 +27,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators this.max = max; } - public Task ValidateAsync(object value, ValidationContext context, Action addError) + public Task ValidateAsync(object value, ValidationContext context, AddError addError) { if (value == null) { @@ -38,15 +38,15 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators if (min.HasValue && typedValue.CompareTo(min.Value) < 0) { - addError($" must be greater or equals than '{min}'."); + addError(context.Path, $"Must be greater than or equal to '{min}'."); } if (max.HasValue && typedValue.CompareTo(max.Value) > 0) { - addError($" must be less or equals than '{max}'."); + addError(context.Path, $"Must be less than or equal to '{max}'."); } return TaskHelper.Done; } } -} \ No newline at end of file +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ReferencesValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ReferencesValidator.cs index a47f0f279..9fd97bc02 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ReferencesValidator.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ReferencesValidator.cs @@ -20,7 +20,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators this.schemaId = schemaId; } - public async Task ValidateAsync(object value, ValidationContext context, Action addError) + public async Task ValidateAsync(object value, ValidationContext context, AddError addError) { if (value is ICollection contentIds) { @@ -28,7 +28,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators foreach (var invalidId in invalidIds) { - addError($" contains invalid reference '{invalidId}'."); + addError(context.Path, $"Contains invalid reference '{invalidId}'."); } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/RequiredStringValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/RequiredStringValidator.cs index fd7a39ad2..2b5219448 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/RequiredStringValidator.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/RequiredStringValidator.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Threading.Tasks; using Squidex.Infrastructure.Tasks; @@ -20,7 +19,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators this.validateEmptyStrings = validateEmptyStrings; } - public Task ValidateAsync(object value, ValidationContext context, Action addError) + public Task ValidateAsync(object value, ValidationContext context, AddError addError) { if (context.IsOptional || (value != null && !(value is string))) { @@ -29,9 +28,9 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators var valueAsString = (string)value; - if (valueAsString == null || (validateEmptyStrings && string.IsNullOrWhiteSpace(valueAsString))) + if (valueAsString == null || validateEmptyStrings && string.IsNullOrWhiteSpace(valueAsString)) { - addError(" is required."); + addError(context.Path, "Field is required."); } return TaskHelper.Done; diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/RequiredValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/RequiredValidator.cs index 4dfeb1849..6d2d308a6 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/RequiredValidator.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/RequiredValidator.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Threading.Tasks; using Squidex.Infrastructure.Tasks; @@ -13,11 +12,11 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators { public class RequiredValidator : IValidator { - public Task ValidateAsync(object value, ValidationContext context, Action addError) + public Task ValidateAsync(object value, ValidationContext context, AddError addError) { if (value == null && !context.IsOptional) { - addError(" is required."); + addError(context.Path, "Field is required."); } return TaskHelper.Done; diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/StringLengthValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/StringLengthValidator.cs index acc8e13e4..f89b1ed3e 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/StringLengthValidator.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/StringLengthValidator.cs @@ -27,18 +27,18 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators this.maxLength = maxLength; } - public Task ValidateAsync(object value, ValidationContext context, Action addError) + public Task ValidateAsync(object value, ValidationContext context, AddError addError) { if (value is string stringValue && !string.IsNullOrEmpty(stringValue)) { if (minLength.HasValue && stringValue.Length < minLength.Value) { - addError($" must have more than '{minLength}' characters."); + addError(context.Path, $"Must have more than '{minLength}' characters."); } if (maxLength.HasValue && stringValue.Length > maxLength.Value) { - addError($" must have less than '{maxLength}' characters."); + addError(context.Path, $"Must have less than '{maxLength}' characters."); } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ValidatorsFactory.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ValidatorsFactory.cs index f6166a617..eba15cb29 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ValidatorsFactory.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ValidatorsFactory.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Newtonsoft.Json.Linq; using NodaTime; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.ValidateContent.Validators; @@ -15,7 +16,7 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.ValidateContent { - public sealed class ValidatorsFactory : IFieldPropertiesVisitor> + public sealed class ValidatorsFactory : IFieldVisitor> { private static readonly ValidatorsFactory Instance = new ValidatorsFactory(); @@ -23,122 +24,139 @@ namespace Squidex.Domain.Apps.Core.ValidateContent { } - public static IEnumerable CreateValidators(Field field) + public static IEnumerable CreateValidators(IField field) { Guard.NotNull(field, nameof(field)); - return field.RawProperties.Accept(Instance); + return field.Accept(Instance); } - public IEnumerable Visit(AssetsFieldProperties properties) + public IEnumerable Visit(IArrayField field) { - if (properties.IsRequired || properties.MinItems.HasValue || properties.MaxItems.HasValue) + if (field.Properties.IsRequired || field.Properties.MinItems.HasValue || field.Properties.MaxItems.HasValue) { - yield return new CollectionValidator(properties.IsRequired, properties.MinItems, properties.MaxItems); + yield return new CollectionValidator(field.Properties.IsRequired, field.Properties.MinItems, field.Properties.MaxItems); } - yield return new AssetsValidator(properties); + var nestedSchema = new Dictionary(); + + foreach (var nestedField in field.Fields) + { + nestedSchema[nestedField.Name] = (false, new FieldValidator(nestedField.Accept(this).ToArray(), nestedField)); + } + + yield return new CollectionItemValidator(new ObjectValidator(nestedSchema, false, "field", JValue.CreateNull())); + } + + public IEnumerable Visit(IField field) + { + if (field.Properties.IsRequired || field.Properties.MinItems.HasValue || field.Properties.MaxItems.HasValue) + { + yield return new CollectionValidator(field.Properties.IsRequired, field.Properties.MinItems, field.Properties.MaxItems); + } + + yield return new AssetsValidator(field.Properties); } - public IEnumerable Visit(BooleanFieldProperties properties) + public IEnumerable Visit(IField field) { - if (properties.IsRequired) + if (field.Properties.IsRequired) { yield return new RequiredValidator(); } } - public IEnumerable Visit(DateTimeFieldProperties properties) + public IEnumerable Visit(IField field) { - if (properties.IsRequired) + if (field.Properties.IsRequired) { yield return new RequiredValidator(); } - if (properties.MinValue.HasValue || properties.MaxValue.HasValue) + if (field.Properties.MinValue.HasValue || field.Properties.MaxValue.HasValue) { - yield return new RangeValidator(properties.MinValue, properties.MaxValue); + yield return new RangeValidator(field.Properties.MinValue, field.Properties.MaxValue); } } - public IEnumerable Visit(GeolocationFieldProperties properties) + public IEnumerable Visit(IField field) { - if (properties.IsRequired) + if (field.Properties.IsRequired) { yield return new RequiredValidator(); } } - public IEnumerable Visit(JsonFieldProperties properties) + public IEnumerable Visit(IField field) { - if (properties.IsRequired) + if (field.Properties.IsRequired) { yield return new RequiredValidator(); } } - public IEnumerable Visit(NumberFieldProperties properties) + public IEnumerable Visit(IField field) { - if (properties.IsRequired) + if (field.Properties.IsRequired) { yield return new RequiredValidator(); } - if (properties.MinValue.HasValue || properties.MaxValue.HasValue) + if (field.Properties.MinValue.HasValue || field.Properties.MaxValue.HasValue) { - yield return new RangeValidator(properties.MinValue, properties.MaxValue); + yield return new RangeValidator(field.Properties.MinValue, field.Properties.MaxValue); } - if (properties.AllowedValues != null) + if (field.Properties.AllowedValues != null) { - yield return new AllowedValuesValidator(properties.AllowedValues.ToArray()); + yield return new AllowedValuesValidator(field.Properties.AllowedValues.ToArray()); } } - public IEnumerable Visit(ReferencesFieldProperties properties) + public IEnumerable Visit(IField field) { - if (properties.IsRequired || properties.MinItems.HasValue || properties.MaxItems.HasValue) + if (field.Properties.IsRequired || field.Properties.MinItems.HasValue || field.Properties.MaxItems.HasValue) { - yield return new CollectionValidator(properties.IsRequired, properties.MinItems, properties.MaxItems); + yield return new CollectionValidator(field.Properties.IsRequired, field.Properties.MinItems, field.Properties.MaxItems); } - if (properties.SchemaId != Guid.Empty) + if (field.Properties.SchemaId != Guid.Empty) { - yield return new ReferencesValidator(properties.SchemaId); + yield return new ReferencesValidator(field.Properties.SchemaId); } } - public IEnumerable Visit(StringFieldProperties properties) + public IEnumerable Visit(IField field) { - if (properties.IsRequired) + if (field.Properties.IsRequired) { yield return new RequiredStringValidator(); } - if (properties.MinLength.HasValue || properties.MaxLength.HasValue) + if (field.Properties.MinLength.HasValue || field.Properties.MaxLength.HasValue) { - yield return new StringLengthValidator(properties.MinLength, properties.MaxLength); + yield return new StringLengthValidator(field.Properties.MinLength, field.Properties.MaxLength); } - if (!string.IsNullOrWhiteSpace(properties.Pattern)) + if (!string.IsNullOrWhiteSpace(field.Properties.Pattern)) { - yield return new PatternValidator(properties.Pattern, properties.PatternMessage); + yield return new PatternValidator(field.Properties.Pattern, field.Properties.PatternMessage); } - if (properties.AllowedValues != null) + if (field.Properties.AllowedValues != null) { - yield return new AllowedValuesValidator(properties.AllowedValues.ToArray()); + yield return new AllowedValuesValidator(field.Properties.AllowedValues.ToArray()); } } - public IEnumerable Visit(TagsFieldProperties properties) + public IEnumerable Visit(IField field) { - if (properties.IsRequired || properties.MinItems.HasValue || properties.MaxItems.HasValue) + if (field.Properties.IsRequired || field.Properties.MinItems.HasValue || field.Properties.MaxItems.HasValue) { - yield return new CollectionValidator(properties.IsRequired, properties.MinItems, properties.MaxItems); + yield return new CollectionValidator(field.Properties.IsRequired, field.Properties.MinItems, field.Properties.MaxItems); } - yield return new CollectionItemValidator(new RequiredStringValidator()); + yield return new CollectionItemValidator(new RequiredStringValidator()); } } } diff --git a/src/Squidex.Domain.Apps.Core/Apps/AppClientPermission.cs b/src/Squidex.Domain.Apps.Core/Apps/AppClientPermission.cs deleted file mode 100644 index 33749b3e1..000000000 --- a/src/Squidex.Domain.Apps.Core/Apps/AppClientPermission.cs +++ /dev/null @@ -1,16 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -namespace Squidex.Domain.Apps.Core.Apps -{ - public enum AppClientPermission - { - Developer, - Editor, - Reader - } -} diff --git a/src/Squidex.Domain.Apps.Core/Apps/AppContributorPermission.cs b/src/Squidex.Domain.Apps.Core/Apps/AppContributorPermission.cs deleted file mode 100644 index a1916542f..000000000 --- a/src/Squidex.Domain.Apps.Core/Apps/AppContributorPermission.cs +++ /dev/null @@ -1,16 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -namespace Squidex.Domain.Apps.Core.Apps -{ - public enum AppContributorPermission - { - Owner, - Developer, - Editor - } -} diff --git a/src/Squidex.Domain.Apps.Core/Apps/AppPermission.cs b/src/Squidex.Domain.Apps.Core/Apps/AppPermission.cs deleted file mode 100644 index db1c13474..000000000 --- a/src/Squidex.Domain.Apps.Core/Apps/AppPermission.cs +++ /dev/null @@ -1,17 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -namespace Squidex.Domain.Apps.Core.Apps -{ - public enum AppPermission - { - Owner, - Developer, - Editor, - Reader - } -} diff --git a/src/Squidex.Domain.Apps.Core/Apps/RoleExtension.cs b/src/Squidex.Domain.Apps.Core/Apps/RoleExtension.cs deleted file mode 100644 index 4a92c3cf9..000000000 --- a/src/Squidex.Domain.Apps.Core/Apps/RoleExtension.cs +++ /dev/null @@ -1,29 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core.Apps -{ - public static class RoleExtension - { - public static AppPermission ToAppPermission(this AppClientPermission clientPermission) - { - Guard.Enum(clientPermission, nameof(clientPermission)); - - return (AppPermission)Enum.Parse(typeof(AppPermission), clientPermission.ToString()); - } - - public static AppPermission ToAppPermission(this AppContributorPermission contributorPermission) - { - Guard.Enum(contributorPermission, nameof(contributorPermission)); - - return (AppPermission)Enum.Parse(typeof(AppPermission), contributorPermission.ToString()); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/ContentEnricher.cs b/src/Squidex.Domain.Apps.Core/ContentEnricher.cs deleted file mode 100644 index c2d117770..000000000 --- a/src/Squidex.Domain.Apps.Core/ContentEnricher.cs +++ /dev/null @@ -1,71 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Squidex.Domain.Apps.Core.Contents; -using Squidex.Domain.Apps.Core.Schemas; -using Squidex.Infrastructure; -using Squidex.Infrastructure.Json; - -namespace Squidex.Domain.Apps.Core -{ - public sealed class ContentEnricher - { - private readonly Schema schema; - private readonly PartitionResolver partitionResolver; - - public ContentEnricher(Schema schema, PartitionResolver partitionResolver) - { - Guard.NotNull(schema, nameof(schema)); - Guard.NotNull(partitionResolver, nameof(partitionResolver)); - - this.schema = schema; - - this.partitionResolver = partitionResolver; - } - - public void Enrich(ContentData data) - { - Guard.NotNull(data, nameof(data)); - - foreach (var field in schema.Fields) - { - var fieldKey = data.GetKey(field); - var fieldData = data.GetOrCreate(fieldKey, k => new ContentFieldData()); - var fieldPartition = partitionResolver(field.Partitioning); - - foreach (var partitionItem in fieldPartition) - { - Enrich(field, fieldData, partitionItem); - } - - if (fieldData.Count > 0) - { - data[fieldKey] = fieldData; - } - } - } - - private static void Enrich(Field field, ContentFieldData fieldData, IFieldPartitionItem partitionItem) - { - Guard.NotNull(fieldData, nameof(fieldData)); - - var defaultValue = field.RawProperties.GetDefaultValue(); - - if (field.RawProperties.IsRequired || defaultValue.IsNull()) - { - return; - } - - var key = partitionItem.Key; - - if (!fieldData.TryGetValue(key, out var value) || field.RawProperties.ShouldApplyDefaultValue(value)) - { - fieldData.AddValue(key, defaultValue); - } - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/ContentExtensions.cs b/src/Squidex.Domain.Apps.Core/ContentExtensions.cs deleted file mode 100644 index 4d0a78900..000000000 --- a/src/Squidex.Domain.Apps.Core/ContentExtensions.cs +++ /dev/null @@ -1,49 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Collections.Generic; -using System.Threading.Tasks; -using Squidex.Domain.Apps.Core.Contents; -using Squidex.Domain.Apps.Core.Schemas; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core -{ - public static class ContentExtensions - { - public static void Enrich(this ContentData data, Schema schema, PartitionResolver partitionResolver) - { - var enricher = new ContentEnricher(schema, partitionResolver); - - enricher.Enrich(data); - } - - public static async Task ValidateAsync(this NamedContentData data, ValidationContext context, Schema schema, PartitionResolver partitionResolver, IList errors) - { - var validator = new ContentValidator(schema, partitionResolver, context); - - await validator.ValidateAsync(data); - - foreach (var error in validator.Errors) - { - errors.Add(error); - } - } - - public static async Task ValidatePartialAsync(this NamedContentData data, ValidationContext context, Schema schema, PartitionResolver partitionResolver, IList errors) - { - var validator = new ContentValidator(schema, partitionResolver, context); - - await validator.ValidatePartialAsync(data); - - foreach (var error in validator.Errors) - { - errors.Add(error); - } - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/ContentValidator.cs b/src/Squidex.Domain.Apps.Core/ContentValidator.cs deleted file mode 100644 index 5e752e541..000000000 --- a/src/Squidex.Domain.Apps.Core/ContentValidator.cs +++ /dev/null @@ -1,141 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Threading.Tasks; -using Newtonsoft.Json.Linq; -using Squidex.Domain.Apps.Core.Contents; -using Squidex.Domain.Apps.Core.Schemas; -using Squidex.Infrastructure; - -#pragma warning disable 168 - -namespace Squidex.Domain.Apps.Core -{ - public sealed class ContentValidator - { - private readonly Schema schema; - private readonly PartitionResolver partitionResolver; - private readonly ValidationContext context; - private readonly ConcurrentBag errors = new ConcurrentBag(); - - public IReadOnlyCollection Errors - { - get { return errors; } - } - - public ContentValidator(Schema schema, PartitionResolver partitionResolver, ValidationContext context) - { - Guard.NotNull(schema, nameof(schema)); - Guard.NotNull(partitionResolver, nameof(partitionResolver)); - - this.schema = schema; - this.context = context; - this.partitionResolver = partitionResolver; - } - - public Task ValidatePartialAsync(NamedContentData data) - { - Guard.NotNull(data, nameof(data)); - - var tasks = new List(); - - foreach (var fieldData in data) - { - var fieldName = fieldData.Key; - - if (!schema.FieldsByName.TryGetValue(fieldData.Key, out var field)) - { - errors.AddError(" is not a known field.", fieldName); - } - else - { - tasks.Add(ValidateFieldPartialAsync(field, fieldData.Value)); - } - } - - return Task.WhenAll(tasks); - } - - private Task ValidateFieldPartialAsync(Field field, ContentFieldData fieldData) - { - var partitioning = field.Partitioning; - var partition = partitionResolver(partitioning); - - var tasks = new List(); - - foreach (var partitionValues in fieldData) - { - if (partition.TryGetItem(partitionValues.Key, out var item)) - { - tasks.Add(field.ValidateAsync(partitionValues.Value, context.Optional(item.IsOptional), m => errors.AddError(m, field, item))); - } - else - { - errors.AddError($" has an unsupported {partitioning.Key} value '{partitionValues.Key}'.", field); - } - } - - return Task.WhenAll(tasks); - } - - public Task ValidateAsync(NamedContentData data) - { - Guard.NotNull(data, nameof(data)); - - ValidateUnknownFields(data); - - var tasks = new List(); - - foreach (var field in schema.FieldsByName.Values) - { - var fieldData = data.GetOrCreate(field.Name, k => new ContentFieldData()); - - tasks.Add(ValidateFieldAsync(field, fieldData)); - } - - return Task.WhenAll(tasks); - } - - private void ValidateUnknownFields(NamedContentData data) - { - foreach (var fieldData in data) - { - if (!schema.FieldsByName.ContainsKey(fieldData.Key)) - { - errors.AddError(" is not a known field.", fieldData.Key); - } - } - } - - private Task ValidateFieldAsync(Field field, ContentFieldData fieldData) - { - var partitioning = field.Partitioning; - var partition = partitionResolver(partitioning); - - var tasks = new List(); - - foreach (var partitionValues in fieldData) - { - if (!partition.TryGetItem(partitionValues.Key, out var _)) - { - errors.AddError($" has an unsupported {partitioning.Key} value '{partitionValues.Key}'.", field); - } - } - - foreach (var item in partition) - { - var value = fieldData.GetOrCreate(item.Key, k => JValue.CreateNull()); - - tasks.Add(field.ValidateAsync(value, context.Optional(item.IsOptional), m => errors.AddError(m, field, item))); - } - - return Task.WhenAll(tasks); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Contents/ContentData.cs b/src/Squidex.Domain.Apps.Core/Contents/ContentData.cs deleted file mode 100644 index 595c6139b..000000000 --- a/src/Squidex.Domain.Apps.Core/Contents/ContentData.cs +++ /dev/null @@ -1,122 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Generic; -using System.Linq; -using Squidex.Domain.Apps.Core.Schemas; -using Squidex.Infrastructure; -using Squidex.Infrastructure.Json; - -namespace Squidex.Domain.Apps.Core.Contents -{ - public abstract class ContentData : Dictionary, IEquatable> - { - public IEnumerable> ValidValues - { - get { return this.Where(x => x.Value != null); } - } - - protected ContentData(IEqualityComparer comparer) - : base(comparer) - { - } - - protected ContentData(IDictionary copy, IEqualityComparer comparer) - : base(copy, comparer) - { - } - - protected static TResult Merge(TResult source, TResult target) where TResult : ContentData - { - if (ReferenceEquals(target, source)) - { - return source; - } - - foreach (var otherValue in source) - { - var fieldValue = target.GetOrAdd(otherValue.Key, x => new ContentFieldData()); - - foreach (var value in otherValue.Value) - { - fieldValue[value.Key] = value.Value; - } - } - - return target; - } - - protected static TResult Clean(TResult source, TResult target) where TResult : ContentData - { - foreach (var fieldValue in source.ValidValues) - { - var resultValue = new ContentFieldData(); - - foreach (var partitionValue in fieldValue.Value.Where(x => !x.Value.IsNull())) - { - resultValue[partitionValue.Key] = partitionValue.Value; - } - - if (resultValue.Count > 0) - { - target[fieldValue.Key] = resultValue; - } - } - - return target; - } - - public IEnumerable GetReferencedIds(Schema schema) - { - Guard.NotNull(schema, nameof(schema)); - - var foundReferences = new HashSet(); - - foreach (var field in schema.Fields) - { - if (field is IReferenceField referenceField) - { - var fieldKey = GetKey(field); - var fieldData = this.GetOrDefault(fieldKey); - - if (fieldData == null) - { - continue; - } - - foreach (var partitionValue in fieldData.Where(x => x.Value != null)) - { - var ids = referenceField.GetReferencedIds(partitionValue.Value); - - foreach (var id in ids.Where(x => foundReferences.Add(x))) - { - yield return id; - } - } - } - } - } - - public override bool Equals(object obj) - { - return Equals(obj as ContentData); - } - - public bool Equals(ContentData other) - { - return other != null && (ReferenceEquals(this, other) || this.EqualsDictionary(other)); - } - - public override int GetHashCode() - { - return this.DictionaryHashCode(); - } - - public abstract T GetKey(Field field); - } -} diff --git a/src/Squidex.Domain.Apps.Core/Contents/ContentFieldData.cs b/src/Squidex.Domain.Apps.Core/Contents/ContentFieldData.cs deleted file mode 100644 index b1ba92b7e..000000000 --- a/src/Squidex.Domain.Apps.Core/Contents/ContentFieldData.cs +++ /dev/null @@ -1,55 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Generic; -using Newtonsoft.Json.Linq; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core.Contents -{ - public sealed class ContentFieldData : Dictionary, IEquatable - { - private static readonly JTokenEqualityComparer JTokenEqualityComparer = new JTokenEqualityComparer(); - - public ContentFieldData() - : base(StringComparer.OrdinalIgnoreCase) - { - } - - public ContentFieldData SetValue(JToken value) - { - this[InvariantPartitioning.Instance.Master.Key] = value; - - return this; - } - - public ContentFieldData AddValue(string key, JToken value) - { - Guard.NotNullOrEmpty(key, nameof(key)); - - this[key] = value; - - return this; - } - - public override bool Equals(object obj) - { - return Equals(obj as ContentFieldData); - } - - public bool Equals(ContentFieldData other) - { - return other != null && (ReferenceEquals(this, other) || this.EqualsDictionary(other, EqualityComparer.Default, JTokenEqualityComparer)); - } - - public override int GetHashCode() - { - return this.DictionaryHashCode(EqualityComparer.Default, JTokenEqualityComparer); - } - } -} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core/Contents/IdContentData.cs b/src/Squidex.Domain.Apps.Core/Contents/IdContentData.cs deleted file mode 100644 index 3c1c5ca57..000000000 --- a/src/Squidex.Domain.Apps.Core/Contents/IdContentData.cs +++ /dev/null @@ -1,130 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using Newtonsoft.Json.Linq; -using Squidex.Domain.Apps.Core.Schemas; -using Squidex.Infrastructure; -using Squidex.Infrastructure.Json; - -namespace Squidex.Domain.Apps.Core.Contents -{ - public sealed class IdContentData : ContentData, IEquatable - { - public IdContentData() - : base(EqualityComparer.Default) - { - } - - public IdContentData(IdContentData copy) - : base(copy, EqualityComparer.Default) - { - } - - public IdContentData MergeInto(IdContentData target) - { - return Merge(this, target); - } - - public IdContentData ToCleaned() - { - return Clean(this, new IdContentData()); - } - - public IdContentData AddField(long id, ContentFieldData data) - { - Guard.GreaterThan(id, 0, nameof(id)); - - this[id] = data; - - return this; - } - - public IdContentData ToCleanedReferences(Schema schema, ISet deletedReferencedIds) - { - var result = new IdContentData(this); - - foreach (var field in schema.Fields) - { - if (field is IReferenceField referenceField) - { - var fieldKey = GetKey(field); - var fieldData = this.GetOrDefault(fieldKey); - - if (fieldData == null) - { - continue; - } - - foreach (var partitionValue in fieldData.Where(x => !x.Value.IsNull()).ToList()) - { - var newValue = referenceField.RemoveDeletedReferences(partitionValue.Value, deletedReferencedIds); - - fieldData[partitionValue.Key] = newValue; - } - } - } - - return result; - } - - public NamedContentData ToNameModel(Schema schema, bool decodeJsonField) - { - Guard.NotNull(schema, nameof(schema)); - - var result = new NamedContentData(); - - foreach (var fieldValue in this) - { - if (!schema.FieldsById.TryGetValue(fieldValue.Key, out var field)) - { - continue; - } - - if (decodeJsonField && field is JsonField) - { - var encodedValue = new ContentFieldData(); - - foreach (var partitionValue in fieldValue.Value) - { - if (partitionValue.Value.IsNull()) - { - encodedValue[partitionValue.Key] = null; - } - else - { - var value = Encoding.UTF8.GetString(Convert.FromBase64String(partitionValue.Value.ToString())); - - encodedValue[partitionValue.Key] = JToken.Parse(value); - } - } - - result[field.Name] = encodedValue; - } - else - { - result[field.Name] = fieldValue.Value; - } - } - - return result; - } - - public bool Equals(IdContentData other) - { - return base.Equals(other); - } - - public override long GetKey(Field field) - { - return field.Id; - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Contents/NamedContentData.cs b/src/Squidex.Domain.Apps.Core/Contents/NamedContentData.cs deleted file mode 100644 index 17dac8d0f..000000000 --- a/src/Squidex.Domain.Apps.Core/Contents/NamedContentData.cs +++ /dev/null @@ -1,191 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using Newtonsoft.Json.Linq; -using Squidex.Domain.Apps.Core.Schemas; -using Squidex.Infrastructure; -using Squidex.Infrastructure.Json; - -namespace Squidex.Domain.Apps.Core.Contents -{ - public sealed class NamedContentData : ContentData, IEquatable - { - public NamedContentData() - : base(StringComparer.OrdinalIgnoreCase) - { - } - - public NamedContentData MergeInto(NamedContentData target) - { - return Merge(this, target); - } - - public NamedContentData ToCleaned() - { - return Clean(this, new NamedContentData()); - } - - public NamedContentData AddField(string name, ContentFieldData data) - { - Guard.NotNullOrEmpty(name, nameof(name)); - - this[name] = data; - - return this; - } - - public IdContentData ToIdModel(Schema schema, bool encodeJsonField) - { - Guard.NotNull(schema, nameof(schema)); - - var result = new IdContentData(); - - foreach (var fieldValue in this) - { - if (!schema.FieldsByName.TryGetValue(fieldValue.Key, out var field)) - { - continue; - } - - var fieldId = field.Id; - - if (encodeJsonField && field is JsonField) - { - var encodedValue = new ContentFieldData(); - - foreach (var partitionValue in fieldValue.Value) - { - if (partitionValue.Value.IsNull()) - { - encodedValue[partitionValue.Key] = null; - } - else - { - var value = Convert.ToBase64String(Encoding.UTF8.GetBytes(partitionValue.Value.ToString())); - - encodedValue[partitionValue.Key] = value; - } - } - - result[fieldId] = encodedValue; - } - else - { - result[fieldId] = fieldValue.Value; - } - } - - return result; - } - - public NamedContentData ToApiModel(Schema schema, LanguagesConfig languagesConfig, bool excludeHidden = true) - { - Guard.NotNull(schema, nameof(schema)); - Guard.NotNull(languagesConfig, nameof(languagesConfig)); - - var codeForInvariant = InvariantPartitioning.Instance.Master.Key; - var codeForMasterLanguage = languagesConfig.Master.Language.Iso2Code; - - var result = new NamedContentData(); - - foreach (var fieldValue in this) - { - if (!schema.FieldsByName.TryGetValue(fieldValue.Key, out var field) || (excludeHidden && field.IsHidden)) - { - continue; - } - - var fieldResult = new ContentFieldData(); - var fieldValues = fieldValue.Value; - - if (field.Partitioning.Equals(Partitioning.Language)) - { - foreach (var languageConfig in languagesConfig) - { - var languageCode = languageConfig.Key; - - if (fieldValues.TryGetValue(languageCode, out var value)) - { - fieldResult.Add(languageCode, value); - } - else if (languageConfig == languagesConfig.Master && fieldValues.TryGetValue(codeForInvariant, out value)) - { - fieldResult.Add(languageCode, value); - } - } - } - else - { - if (fieldValues.TryGetValue(codeForInvariant, out var value)) - { - fieldResult.Add(codeForInvariant, value); - } - else if (fieldValues.TryGetValue(codeForMasterLanguage, out value)) - { - fieldResult.Add(codeForInvariant, value); - } - else if (fieldValues.Count > 0) - { - fieldResult.Add(codeForInvariant, fieldValues.Values.First()); - } - } - - result.Add(GetKey(field), fieldResult); - } - - return result; - } - - public object ToLanguageModel(LanguagesConfig languagesConfig, IReadOnlyCollection languagePreferences = null) - { - Guard.NotNull(languagesConfig, nameof(languagesConfig)); - - if (languagePreferences == null || languagePreferences.Count == 0) - { - return this; - } - - if (languagePreferences.Count == 1 && languagesConfig.TryGetConfig(languagePreferences.First(), out var languageConfig)) - { - languagePreferences = languagePreferences.Union(languageConfig.LanguageFallbacks).ToList(); - } - - var result = new Dictionary(); - - foreach (var fieldValue in this) - { - var fieldValues = fieldValue.Value; - - foreach (var language in languagePreferences) - { - if (fieldValues.TryGetValue(language, out var value) && value != null) - { - result[fieldValue.Key] = value; - - break; - } - } - } - - return result; - } - - public bool Equals(NamedContentData other) - { - return base.Equals(other); - } - - public override string GetKey(Field field) - { - return field.Name; - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Contents/Status.cs b/src/Squidex.Domain.Apps.Core/Contents/Status.cs deleted file mode 100644 index c20e0c4eb..000000000 --- a/src/Squidex.Domain.Apps.Core/Contents/Status.cs +++ /dev/null @@ -1,16 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -namespace Squidex.Domain.Apps.Core.Contents -{ - public enum Status - { - Draft, - Archived, - Published - } -} diff --git a/src/Squidex.Domain.Apps.Core/Contents/StatusFlow.cs b/src/Squidex.Domain.Apps.Core/Contents/StatusFlow.cs deleted file mode 100644 index 005b2d4b3..000000000 --- a/src/Squidex.Domain.Apps.Core/Contents/StatusFlow.cs +++ /dev/null @@ -1,32 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Collections.Generic; -using System.Linq; - -namespace Squidex.Domain.Apps.Core.Contents -{ - public static class StatusFlow - { - private static readonly Dictionary Flow = new Dictionary - { - [Status.Draft] = new[] { Status.Published, Status.Archived }, - [Status.Archived] = new[] { Status.Draft }, - [Status.Published] = new[] { Status.Draft, Status.Archived } - }; - - public static bool Exists(Status status) - { - return Flow.ContainsKey(status); - } - - public static bool CanChange(Status status, Status toStatus) - { - return Flow.TryGetValue(status, out var state) && state.Contains(toStatus); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/FieldExtensions.cs b/src/Squidex.Domain.Apps.Core/FieldExtensions.cs deleted file mode 100644 index 902a96fb1..000000000 --- a/src/Squidex.Domain.Apps.Core/FieldExtensions.cs +++ /dev/null @@ -1,57 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Concurrent; -using System.Threading.Tasks; -using Newtonsoft.Json.Linq; -using Squidex.Domain.Apps.Core.Schemas; -using Squidex.Infrastructure; -using Squidex.Infrastructure.Json; - -namespace Squidex.Domain.Apps.Core -{ - public static class FieldExtensions - { - public static void AddError(this ConcurrentBag errors, string message, Field field, IFieldPartitionItem partitionItem = null) - { - AddError(errors, message, !string.IsNullOrWhiteSpace(field.RawProperties.Label) ? field.RawProperties.Label : field.Name, field.Name, partitionItem); - } - - public static void AddError(this ConcurrentBag errors, string message, string fieldName, IFieldPartitionItem partitionItem = null) - { - AddError(errors, message, fieldName, fieldName, partitionItem); - } - - public static void AddError(this ConcurrentBag errors, string message, string displayName, string fieldName, IFieldPartitionItem partitionItem = null) - { - if (partitionItem != null && partitionItem != InvariantPartitioning.Instance.Master) - { - displayName += $" ({partitionItem.Key})"; - } - - errors.Add(new ValidationError(message.Replace("", displayName), fieldName)); - } - - public static async Task ValidateAsync(this Field field, JToken value, ValidationContext context, Action addError) - { - try - { - var typedValue = value.IsNull() ? null : field.ConvertValue(value); - - foreach (var validator in field.Validators) - { - await validator.ValidateAsync(typedValue, context, addError); - } - } - catch - { - addError(" is not a valid value."); - } - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/IFieldPartitionItem.cs b/src/Squidex.Domain.Apps.Core/IFieldPartitionItem.cs deleted file mode 100644 index 4dd332cc1..000000000 --- a/src/Squidex.Domain.Apps.Core/IFieldPartitionItem.cs +++ /dev/null @@ -1,22 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Collections.Generic; - -namespace Squidex.Domain.Apps.Core -{ - public interface IFieldPartitionItem - { - string Key { get; } - - string Name { get; } - - bool IsOptional { get; } - - IEnumerable Fallback { get; } - } -} diff --git a/src/Squidex.Domain.Apps.Core/IFieldPartitioning.cs b/src/Squidex.Domain.Apps.Core/IFieldPartitioning.cs deleted file mode 100644 index 9537a4fce..000000000 --- a/src/Squidex.Domain.Apps.Core/IFieldPartitioning.cs +++ /dev/null @@ -1,18 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Collections.Generic; - -namespace Squidex.Domain.Apps.Core -{ - public interface IFieldPartitioning : IReadOnlyCollection - { - IFieldPartitionItem Master { get; } - - bool TryGetItem(string key, out IFieldPartitionItem item); - } -} diff --git a/src/Squidex.Domain.Apps.Core/InvariantPartitioning.cs b/src/Squidex.Domain.Apps.Core/InvariantPartitioning.cs deleted file mode 100644 index f9c439b56..000000000 --- a/src/Squidex.Domain.Apps.Core/InvariantPartitioning.cs +++ /dev/null @@ -1,72 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; - -namespace Squidex.Domain.Apps.Core -{ - public sealed class InvariantPartitioning : IFieldPartitioning, IFieldPartitionItem - { - public static readonly InvariantPartitioning Instance = new InvariantPartitioning(); - - public int Count - { - get { return 1; } - } - - public IFieldPartitionItem Master - { - get { return this; } - } - - string IFieldPartitionItem.Key - { - get { return "iv"; } - } - - string IFieldPartitionItem.Name - { - get { return "Invariant"; } - } - - bool IFieldPartitionItem.IsOptional - { - get { return false; } - } - - IEnumerable IFieldPartitionItem.Fallback - { - get { return Enumerable.Empty(); } - } - - private InvariantPartitioning() - { - } - - public bool TryGetItem(string key, out IFieldPartitionItem item) - { - var isFound = string.Equals(key, "iv", StringComparison.OrdinalIgnoreCase); - - item = isFound ? this : null; - - return isFound; - } - - IEnumerator IEnumerable.GetEnumerator() - { - yield return this; - } - - IEnumerator IEnumerable.GetEnumerator() - { - yield return this; - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/LanguageConfig.cs b/src/Squidex.Domain.Apps.Core/LanguageConfig.cs deleted file mode 100644 index b9350ed5a..000000000 --- a/src/Squidex.Domain.Apps.Core/LanguageConfig.cs +++ /dev/null @@ -1,58 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core -{ - public sealed class LanguageConfig : IFieldPartitionItem - { - public bool IsOptional { get; } - - public Language Language { get; } - - public ImmutableList LanguageFallbacks { get; } - - public string Key - { - get { return Language.Iso2Code; } - } - - public string Name - { - get { return Language.EnglishName; } - } - - IEnumerable IFieldPartitionItem.Fallback - { - get { return LanguageFallbacks.Select(x => x.Iso2Code); } - } - - public LanguageConfig(Language language, bool isOptional, params Language[] fallback) - : this(language, isOptional, fallback?.ToImmutableList()) - { - } - - public LanguageConfig(Language language, bool isOptional, IEnumerable fallback) - : this(language, isOptional, fallback?.ToImmutableList()) - { - } - - public LanguageConfig(Language language, bool isOptional = false, ImmutableList fallback = null) - { - Guard.NotNull(language, nameof(language)); - - IsOptional = isOptional; - - Language = language; - LanguageFallbacks = fallback ?? ImmutableList.Empty; - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/LanguagesConfig.cs b/src/Squidex.Domain.Apps.Core/LanguagesConfig.cs deleted file mode 100644 index e966392a9..000000000 --- a/src/Squidex.Domain.Apps.Core/LanguagesConfig.cs +++ /dev/null @@ -1,218 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core -{ - public sealed class LanguagesConfig : IFieldPartitioning - { - private readonly ImmutableDictionary languages; - private readonly LanguageConfig master; - - public static readonly LanguagesConfig Empty = Create(); - - public LanguageConfig Master - { - get { return master; } - } - - public int Count - { - get { return languages.Count; } - } - - IFieldPartitionItem IFieldPartitioning.Master - { - get { return Master; } - } - - IEnumerator IEnumerable.GetEnumerator() - { - return languages.Values.GetEnumerator(); - } - - IEnumerator IEnumerable.GetEnumerator() - { - return languages.Values.GetEnumerator(); - } - - private LanguagesConfig(ImmutableDictionary languages, LanguageConfig master) - { - this.languages = ValidateLanguages(languages); - - this.master = master; - } - - public static LanguagesConfig Create(ICollection languageConfigs) - { - Guard.NotNull(languageConfigs, nameof(languageConfigs)); - - var validated = ValidateLanguages(languageConfigs.ToImmutableDictionary(c => c.Language)); - - return new LanguagesConfig(validated, languageConfigs.FirstOrDefault()); - } - - public static LanguagesConfig Create(params Language[] languages) - { - Guard.NotNull(languages, nameof(languages)); - - var languageConfigs = languages.Select(l => new LanguageConfig(l)).ToList(); - - return Create(languageConfigs); - } - - public LanguagesConfig MakeMaster(Language language) - { - ThrowIfNotFound(language); - - return new LanguagesConfig(languages, languages[language]); - } - - public LanguagesConfig Add(Language language) - { - ThrowIfFound(language, () => $"Cannot add language '{language.Iso2Code}'."); - - var newLanguages = languages.Add(language, new LanguageConfig(language)); - - return new LanguagesConfig(newLanguages, master ?? newLanguages.Values.First()); - } - - public LanguagesConfig Update(Language language, bool isOptional, bool isMaster, IEnumerable fallback) - { - ThrowIfNotFound(language); - - if (isOptional) - { - ThrowIfMaster(language, isMaster, () => $"Cannot cannot make language '{language.Iso2Code}' optional"); - } - - var newLanguage = new LanguageConfig(language, isOptional, fallback); - var newLanguages = ValidateLanguages(languages.SetItem(language, newLanguage)); - - return new LanguagesConfig(newLanguages, isMaster ? newLanguage : master); - } - - public LanguagesConfig Remove(Language language) - { - ThrowIfNotFound(language); - ThrowIfMaster(language, false, () => $"Cannot remove language '{language.Iso2Code}'"); - - var newLanguages = languages.Remove(language); - - foreach (var languageConfig in newLanguages.Values) - { - if (languageConfig.LanguageFallbacks.Contains(language)) - { - newLanguages = - newLanguages.SetItem(languageConfig.Language, - new LanguageConfig( - languageConfig.Language, - languageConfig.IsOptional, - languageConfig.LanguageFallbacks.Remove(language))); - } - } - - return new LanguagesConfig(newLanguages, master); - } - - public bool Contains(Language language) - { - return language != null && languages.ContainsKey(language); - } - - public bool TryGetConfig(Language language, out LanguageConfig config) - { - return languages.TryGetValue(language, out config); - } - - public bool TryGetItem(string key, out IFieldPartitionItem item) - { - if (Language.IsValidLanguage(key) && languages.TryGetValue(key, out var value)) - { - item = value; - - return true; - } - - item = null; - - return false; - } - - private static ImmutableDictionary ValidateLanguages(ImmutableDictionary languages) - { - var errors = new List(); - - foreach (var languageConfig in languages.Values) - { - foreach (var fallback in languageConfig.LanguageFallbacks) - { - if (!languages.ContainsKey(fallback)) - { - var message = $"Config for language '{languageConfig.Language.Iso2Code}' contains unsupported fallback language '{fallback.Iso2Code}'"; - - errors.Add(new ValidationError(message)); - } - } - } - - if (errors.Count > 0) - { - throw new ValidationException("Cannot configure language.", errors); - } - - return languages; - } - - private void ThrowIfNotFound(Language language) - { - if (!Contains(language)) - { - throw new DomainObjectNotFoundException(language, "Languages", typeof(LanguagesConfig)); - } - } - - private void ThrowIfFound(Language language, Func message) - { - if (Contains(language)) - { - var error = new ValidationError("Language is already part of the app.", "Language"); - - throw new ValidationException(message(), error); - } - } - - private void ThrowIfMaster(Language language, bool isMaster, Func message) - { - if (master?.Language == language || isMaster) - { - var error = new ValidationError("Language is the master language.", "Language"); - - throw new ValidationException(message(), error); - } - } - - public PartitionResolver ToResolver() - { - return partitioning => - { - if (partitioning.Equals(Partitioning.Invariant)) - { - return InvariantPartitioning.Instance; - } - - return this; - }; - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Partitioning.cs b/src/Squidex.Domain.Apps.Core/Partitioning.cs deleted file mode 100644 index f6600ede5..000000000 --- a/src/Squidex.Domain.Apps.Core/Partitioning.cs +++ /dev/null @@ -1,49 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core -{ - public delegate IFieldPartitioning PartitionResolver(Partitioning key); - - public sealed class Partitioning : IEquatable - { - public static readonly Partitioning Invariant = new Partitioning("invariant"); - public static readonly Partitioning Language = new Partitioning("language"); - - public string Key { get; } - - public Partitioning(string key) - { - Guard.NotNullOrEmpty(key, nameof(key)); - - Key = key; - } - - public override bool Equals(object obj) - { - return Equals(obj as Partitioning); - } - - public bool Equals(Partitioning other) - { - return string.Equals(other?.Key, Key, StringComparison.OrdinalIgnoreCase); - } - - public override int GetHashCode() - { - return Key.GetHashCode(); - } - - public override string ToString() - { - return Key; - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/PartitioningExtensions.cs b/src/Squidex.Domain.Apps.Core/PartitioningExtensions.cs deleted file mode 100644 index 089fc0ae6..000000000 --- a/src/Squidex.Domain.Apps.Core/PartitioningExtensions.cs +++ /dev/null @@ -1,26 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Generic; - -namespace Squidex.Domain.Apps.Core -{ - public static class PartitioningExtensions - { - private static readonly HashSet AllowedPartitions = new HashSet(StringComparer.OrdinalIgnoreCase) - { - Partitioning.Language.Key, - Partitioning.Invariant.Key - }; - - public static bool IsValidPartitioning(this string value) - { - return value == null || AllowedPartitions.Contains(value); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/AssetsField.cs b/src/Squidex.Domain.Apps.Core/Schemas/AssetsField.cs deleted file mode 100644 index 1291ce652..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/AssetsField.cs +++ /dev/null @@ -1,79 +0,0 @@ -// ========================================================================== -// 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.Collections.Immutable; -using System.Linq; -using Newtonsoft.Json.Linq; -using Squidex.Domain.Apps.Core.Schemas.Validators; - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public sealed class AssetsField : Field, IReferenceField - { - private static readonly ImmutableList EmptyIds = ImmutableList.Empty; - - public AssetsField(long id, string name, Partitioning partitioning) - : this(id, name, partitioning, new AssetsFieldProperties()) - { - } - - public AssetsField(long id, string name, Partitioning partitioning, AssetsFieldProperties properties) - : base(id, name, partitioning, properties) - { - } - - protected override IEnumerable CreateValidators() - { - if (Properties.IsRequired || Properties.MinItems.HasValue || Properties.MaxItems.HasValue) - { - yield return new CollectionValidator(Properties.IsRequired, Properties.MinItems, Properties.MaxItems); - } - - yield return new AssetsValidator(); - } - - public IEnumerable GetReferencedIds(JToken value) - { - IEnumerable result = null; - try - { - result = value?.ToObject>(); - } - catch - { - result = EmptyIds; - } - - return result ?? EmptyIds; - } - - public JToken RemoveDeletedReferences(JToken value, ISet deletedReferencedIds) - { - if (value == null || value.Type == JTokenType.Null) - { - return null; - } - - var oldAssetIds = GetReferencedIds(value).ToArray(); - var newAssetIds = oldAssetIds.Where(x => !deletedReferencedIds.Contains(x)).ToList(); - - return newAssetIds.Count != oldAssetIds.Length ? JToken.FromObject(newAssetIds) : value; - } - - public override object ConvertValue(JToken value) - { - return value.ToObject>(); - } - - public override T Accept(IFieldVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/AssetsFieldProperties.cs b/src/Squidex.Domain.Apps.Core/Schemas/AssetsFieldProperties.cs deleted file mode 100644 index fab566c36..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/AssetsFieldProperties.cs +++ /dev/null @@ -1,57 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Newtonsoft.Json.Linq; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core.Schemas -{ - [TypeName(nameof(AssetsField))] - public sealed class AssetsFieldProperties : FieldProperties - { - private int? minItems; - private int? maxItems; - - public int? MinItems - { - get - { - return minItems; - } - set - { - ThrowIfFrozen(); - - minItems = value; - } - } - - public int? MaxItems - { - get - { - return maxItems; - } - set - { - ThrowIfFrozen(); - - maxItems = value; - } - } - - public override JToken GetDefaultValue() - { - return new JArray(); - } - - public override T Accept(IFieldPropertiesVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/BooleanField.cs b/src/Squidex.Domain.Apps.Core/Schemas/BooleanField.cs deleted file mode 100644 index 1a8019a69..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/BooleanField.cs +++ /dev/null @@ -1,44 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Collections.Generic; -using Newtonsoft.Json.Linq; -using Squidex.Domain.Apps.Core.Schemas.Validators; - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public sealed class BooleanField : Field - { - public BooleanField(long id, string name, Partitioning partitioning) - : this(id, name, partitioning, new BooleanFieldProperties()) - { - } - - public BooleanField(long id, string name, Partitioning partitioning, BooleanFieldProperties properties) - : base(id, name, partitioning, properties) - { - } - - protected override IEnumerable CreateValidators() - { - if (Properties.IsRequired) - { - yield return new RequiredValidator(); - } - } - - public override object ConvertValue(JToken value) - { - return (bool?)value; - } - - public override T Accept(IFieldVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/BooleanFieldEditor.cs b/src/Squidex.Domain.Apps.Core/Schemas/BooleanFieldEditor.cs deleted file mode 100644 index 08374e9a6..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/BooleanFieldEditor.cs +++ /dev/null @@ -1,15 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public enum BooleanFieldEditor - { - Checkbox, - Toggle - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/BooleanFieldProperties.cs b/src/Squidex.Domain.Apps.Core/Schemas/BooleanFieldProperties.cs deleted file mode 100644 index ec5b79610..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/BooleanFieldProperties.cs +++ /dev/null @@ -1,57 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Newtonsoft.Json.Linq; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core.Schemas -{ - [TypeName(nameof(BooleanField))] - public sealed class BooleanFieldProperties : FieldProperties - { - private BooleanFieldEditor editor; - private bool? defaultValue; - - public bool? DefaultValue - { - get - { - return defaultValue; - } - set - { - ThrowIfFrozen(); - - defaultValue = value; - } - } - - public BooleanFieldEditor Editor - { - get - { - return editor; - } - set - { - ThrowIfFrozen(); - - editor = value; - } - } - - public override JToken GetDefaultValue() - { - return DefaultValue; - } - - public override T Accept(IFieldPropertiesVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/CloneableBase.cs b/src/Squidex.Domain.Apps.Core/Schemas/CloneableBase.cs deleted file mode 100644 index 59114e71d..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/CloneableBase.cs +++ /dev/null @@ -1,23 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public abstract class CloneableBase - { - protected T Clone(Action updater) where T : CloneableBase - { - var clone = (T)MemberwiseClone(); - - updater(clone); - - return clone; - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/DateTimeCalculatedDefaultValue.cs b/src/Squidex.Domain.Apps.Core/Schemas/DateTimeCalculatedDefaultValue.cs deleted file mode 100644 index 41f2b9534..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/DateTimeCalculatedDefaultValue.cs +++ /dev/null @@ -1,15 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public enum DateTimeCalculatedDefaultValue - { - Now, - Today - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/DateTimeField.cs b/src/Squidex.Domain.Apps.Core/Schemas/DateTimeField.cs deleted file mode 100644 index 85ea8cf51..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/DateTimeField.cs +++ /dev/null @@ -1,64 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Generic; -using Newtonsoft.Json.Linq; -using NodaTime; -using NodaTime.Text; -using Squidex.Domain.Apps.Core.Schemas.Validators; - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public sealed class DateTimeField : Field - { - public DateTimeField(long id, string name, Partitioning partitioning) - : this(id, name, partitioning, new DateTimeFieldProperties()) - { - } - - public DateTimeField(long id, string name, Partitioning partitioning, DateTimeFieldProperties properties) - : base(id, name, partitioning, properties) - { - } - - protected override IEnumerable CreateValidators() - { - if (Properties.IsRequired) - { - yield return new RequiredValidator(); - } - - if (Properties.MinValue.HasValue || Properties.MaxValue.HasValue) - { - yield return new RangeValidator(Properties.MinValue, Properties.MaxValue); - } - } - - public override object ConvertValue(JToken value) - { - if (value.Type == JTokenType.String) - { - var parseResult = InstantPattern.General.Parse(value.ToString()); - - if (!parseResult.Success) - { - throw parseResult.Exception; - } - - return parseResult.Value; - } - - throw new InvalidCastException("Invalid json type, expected string."); - } - - public override T Accept(IFieldVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/DateTimeFieldEditor.cs b/src/Squidex.Domain.Apps.Core/Schemas/DateTimeFieldEditor.cs deleted file mode 100644 index ffe493a8f..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/DateTimeFieldEditor.cs +++ /dev/null @@ -1,15 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public enum DateTimeFieldEditor - { - Date, - DateTime - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/DateTimeFieldProperties.cs b/src/Squidex.Domain.Apps.Core/Schemas/DateTimeFieldProperties.cs deleted file mode 100644 index 3873185e9..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/DateTimeFieldProperties.cs +++ /dev/null @@ -1,115 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using Newtonsoft.Json.Linq; -using NodaTime; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core.Schemas -{ - [TypeName(nameof(DateTimeField))] - public sealed class DateTimeFieldProperties : FieldProperties - { - private DateTimeFieldEditor editor; - private DateTimeCalculatedDefaultValue? calculatedDefaultValue; - private Instant? maxValue; - private Instant? minValue; - private Instant? defaultValue; - - public Instant? MaxValue - { - get - { - return maxValue; - } - set - { - ThrowIfFrozen(); - - maxValue = value; - } - } - - public Instant? MinValue - { - get - { - return minValue; - } - set - { - ThrowIfFrozen(); - - minValue = value; - } - } - - public Instant? DefaultValue - { - get - { - return defaultValue; - } - set - { - ThrowIfFrozen(); - - defaultValue = value; - } - } - - public DateTimeCalculatedDefaultValue? CalculatedDefaultValue - { - get - { - return calculatedDefaultValue; - } - set - { - ThrowIfFrozen(); - - calculatedDefaultValue = value; - } - } - - public DateTimeFieldEditor Editor - { - get - { - return editor; - } - set - { - ThrowIfFrozen(); - - editor = value; - } - } - - public override JToken GetDefaultValue() - { - if (CalculatedDefaultValue == DateTimeCalculatedDefaultValue.Now) - { - return DateTime.UtcNow.ToString("o"); - } - else if (CalculatedDefaultValue == DateTimeCalculatedDefaultValue.Today) - { - return DateTime.UtcNow.Date.ToString("o"); - } - else - { - return DefaultValue?.ToString(); - } - } - - public override T Accept(IFieldPropertiesVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/Edm/EdmSchemaExtensions.cs b/src/Squidex.Domain.Apps.Core/Schemas/Edm/EdmSchemaExtensions.cs deleted file mode 100644 index ff38f0e67..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/Edm/EdmSchemaExtensions.cs +++ /dev/null @@ -1,59 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Linq; -using Microsoft.OData.Edm; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core.Schemas.Edm -{ - public static class EdmSchemaExtensions - { - public static string EscapeEdmField(this string field) - { - return field.Replace("-", "_"); - } - - public static string UnescapeEdmField(this string field) - { - return field.Replace("_", "-"); - } - - public static EdmComplexType BuildEdmType(this Schema schema, PartitionResolver partitionResolver, Func typeResolver) - { - Guard.NotNull(typeResolver, nameof(typeResolver)); - Guard.NotNull(partitionResolver, nameof(partitionResolver)); - - var schemaName = schema.Name.ToPascalCase(); - - var edmType = new EdmComplexType("Squidex", schemaName); - - foreach (var field in schema.FieldsByName.Values.Where(x => !x.IsHidden)) - { - var edmValueType = EdmTypeVisitor.CreateEdmType(field); - - if (edmValueType == null) - { - continue; - } - - var partitionType = typeResolver(new EdmComplexType("Squidex", $"{schemaName}{field.Name.ToPascalCase()}Property")); - var partition = partitionResolver(field.Partitioning); - - foreach (var partitionItem in partition) - { - partitionType.AddStructuralProperty(partitionItem.Key, edmValueType); - } - - edmType.AddStructuralProperty(field.Name.EscapeEdmField(), new EdmComplexTypeReference(partitionType, false)); - } - - return edmType; - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/Edm/EdmTypeVisitor.cs b/src/Squidex.Domain.Apps.Core/Schemas/Edm/EdmTypeVisitor.cs deleted file mode 100644 index 4b80bfffa..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/Edm/EdmTypeVisitor.cs +++ /dev/null @@ -1,75 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Microsoft.OData.Edm; - -namespace Squidex.Domain.Apps.Core.Schemas.Edm -{ - public sealed class EdmTypeVisitor : IFieldVisitor - { - private static readonly EdmTypeVisitor Instance = new EdmTypeVisitor(); - - private EdmTypeVisitor() - { - } - - public static IEdmTypeReference CreateEdmType(Field field) - { - return field.Accept(Instance); - } - - public IEdmTypeReference Visit(AssetsField field) - { - return CreatePrimitive(EdmPrimitiveTypeKind.String, field); - } - - public IEdmTypeReference Visit(BooleanField field) - { - return CreatePrimitive(EdmPrimitiveTypeKind.Boolean, field); - } - - public IEdmTypeReference Visit(DateTimeField field) - { - return CreatePrimitive(EdmPrimitiveTypeKind.DateTimeOffset, field); - } - - public IEdmTypeReference Visit(GeolocationField field) - { - return null; - } - - public IEdmTypeReference Visit(JsonField field) - { - return null; - } - - public IEdmTypeReference Visit(NumberField field) - { - return CreatePrimitive(EdmPrimitiveTypeKind.Double, field); - } - - public IEdmTypeReference Visit(ReferencesField field) - { - return CreatePrimitive(EdmPrimitiveTypeKind.String, field); - } - - public IEdmTypeReference Visit(StringField field) - { - return CreatePrimitive(EdmPrimitiveTypeKind.String, field); - } - - public IEdmTypeReference Visit(TagsField field) - { - return CreatePrimitive(EdmPrimitiveTypeKind.String, field); - } - - private static IEdmTypeReference CreatePrimitive(EdmPrimitiveTypeKind kind, Field field) - { - return EdmCoreModel.Instance.GetPrimitive(kind, !field.RawProperties.IsRequired); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/Field.cs b/src/Squidex.Domain.Apps.Core/Schemas/Field.cs deleted file mode 100644 index 0f4b9b863..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/Field.cs +++ /dev/null @@ -1,130 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Generic; -using Newtonsoft.Json.Linq; -using Squidex.Domain.Apps.Core.Schemas.Validators; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public abstract class Field : CloneableBase - { - private readonly Lazy> validators; - private readonly long fieldId; - private readonly Partitioning partitioning; - private readonly string fieldName; - private bool isDisabled; - private bool isHidden; - private bool isLocked; - - public long Id - { - get { return fieldId; } - } - - public string Name - { - get { return fieldName; } - } - - public bool IsLocked - { - get { return isLocked; } - } - - public bool IsHidden - { - get { return isHidden; } - } - - public bool IsDisabled - { - get { return isDisabled; } - } - - public Partitioning Partitioning - { - get { return partitioning; } - } - - public IReadOnlyList Validators - { - get { return validators.Value; } - } - - public abstract FieldProperties RawProperties { get; } - - protected Field(long id, string name, Partitioning partitioning) - { - Guard.NotNullOrEmpty(name, nameof(name)); - Guard.NotNull(partitioning, nameof(partitioning)); - Guard.GreaterThan(id, 0, nameof(id)); - - fieldId = id; - fieldName = name; - - this.partitioning = partitioning; - - validators = new Lazy>(() => new List(CreateValidators())); - } - - protected abstract Field UpdateInternal(FieldProperties newProperties); - - protected abstract IEnumerable CreateValidators(); - - public abstract object ConvertValue(JToken value); - - public Field Lock() - { - return Clone(clone => - { - clone.isLocked = true; - }); - } - - public Field Hide() - { - return Clone(clone => - { - clone.isHidden = true; - }); - } - - public Field Show() - { - return Clone(clone => - { - clone.isHidden = false; - }); - } - - public Field Disable() - { - return Clone(clone => - { - clone.isDisabled = true; - }); - } - - public Field Enable() - { - return Clone(clone => - { - clone.isDisabled = false; - }); - } - - public Field Update(FieldProperties newProperties) - { - return UpdateInternal(newProperties); - } - - public abstract T Accept(IFieldVisitor visitor); - } -} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core/Schemas/FieldProperties.cs b/src/Squidex.Domain.Apps.Core/Schemas/FieldProperties.cs deleted file mode 100644 index 73ad8b34e..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/FieldProperties.cs +++ /dev/null @@ -1,70 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Newtonsoft.Json.Linq; -using Squidex.Infrastructure.Json; - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public abstract class FieldProperties : NamedElementPropertiesBase - { - private bool isRequired; - private bool isListField; - private string placeholder; - - public bool IsRequired - { - get - { - return isRequired; - } - set - { - ThrowIfFrozen(); - - isRequired = value; - } - } - - public bool IsListField - { - get - { - return isListField; - } - set - { - ThrowIfFrozen(); - - isListField = value; - } - } - - public string Placeholder - { - get - { - return placeholder; - } - set - { - ThrowIfFrozen(); - - placeholder = value; - } - } - - public abstract JToken GetDefaultValue(); - - public abstract T Accept(IFieldPropertiesVisitor visitor); - - public virtual bool ShouldApplyDefaultValue(JToken value) - { - return value.IsNull(); - } - } -} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core/Schemas/FieldRegistry.cs b/src/Squidex.Domain.Apps.Core/Schemas/FieldRegistry.cs deleted file mode 100644 index 1aa8e964b..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/FieldRegistry.cs +++ /dev/null @@ -1,115 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Generic; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public sealed class FieldRegistry - { - private delegate Field FactoryFunction(long id, string name, Partitioning partitioning, FieldProperties properties); - - private readonly TypeNameRegistry typeNameRegistry; - private readonly Dictionary fieldsByPropertyType = new Dictionary(); - - private sealed class Registered - { - private readonly FactoryFunction fieldFactory; - private readonly Type propertiesType; - - public Type PropertiesType - { - get { return propertiesType; } - } - - public Registered(FactoryFunction fieldFactory, Type propertiesType) - { - this.fieldFactory = fieldFactory; - this.propertiesType = propertiesType; - } - - public Field CreateField(long id, string name, Partitioning partitioning, FieldProperties properties) - { - return fieldFactory(id, name, partitioning, properties); - } - } - - public FieldRegistry(TypeNameRegistry typeNameRegistry) - { - Guard.NotNull(typeNameRegistry, nameof(typeNameRegistry)); - - this.typeNameRegistry = typeNameRegistry; - - Add( - (id, name, partitioning, properties) => - new BooleanField(id, name, partitioning, (BooleanFieldProperties)properties)); - - Add( - (id, name, partitioning, properties) => - new NumberField(id, name, partitioning, (NumberFieldProperties)properties)); - - Add( - (id, name, partitioning, properties) => - new StringField(id, name, partitioning, (StringFieldProperties)properties)); - - Add( - (id, name, partitioning, properties) => - new JsonField(id, name, partitioning, (JsonFieldProperties)properties)); - - Add( - (id, name, partitioning, properties) => - new AssetsField(id, name, partitioning, (AssetsFieldProperties)properties)); - - Add( - (id, name, partitioning, properties) => - new GeolocationField(id, name, partitioning, (GeolocationFieldProperties)properties)); - - Add( - (id, name, partitioning, properties) => - new ReferencesField(id, name, partitioning, (ReferencesFieldProperties)properties)); - - Add( - (id, name, partitioning, properties) => - new DateTimeField(id, name, partitioning, (DateTimeFieldProperties)properties)); - - Add( - (id, name, partitioning, properties) => - new TagsField(id, name, partitioning, (TagsFieldProperties)properties)); - - typeNameRegistry.MapObsolete(typeof(ReferencesFieldProperties), "DateTime"); - - typeNameRegistry.MapObsolete(typeof(DateTimeFieldProperties), "References"); - } - - private void Add(FactoryFunction fieldFactory) - { - Guard.NotNull(fieldFactory, nameof(fieldFactory)); - - typeNameRegistry.Map(typeof(TFieldProperties)); - - var registered = new Registered(fieldFactory, typeof(TFieldProperties)); - - fieldsByPropertyType[registered.PropertiesType] = registered; - } - - public Field CreateField(long id, string name, Partitioning partitioning, FieldProperties properties) - { - Guard.NotNull(properties, nameof(properties)); - - var registered = fieldsByPropertyType.GetOrDefault(properties.GetType()); - - if (registered == null) - { - throw new InvalidOperationException($"The field property '{properties.GetType()}' is not supported."); - } - - return registered.CreateField(id, name, partitioning, properties); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/Field{T}.cs b/src/Squidex.Domain.Apps.Core/Schemas/Field{T}.cs deleted file mode 100644 index 55a3588a0..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/Field{T}.cs +++ /dev/null @@ -1,55 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using Squidex.Infrastructure; -namespace Squidex.Domain.Apps.Core.Schemas -{ - public abstract class Field : Field where T : FieldProperties - { - private T properties; - - public T Properties - { - get { return properties; } - } - - public override FieldProperties RawProperties - { - get { return properties; } - } - - protected Field(long id, string name, Partitioning partitioning, T properties) - : base(id, name, partitioning) - { - Guard.NotNull(properties, nameof(properties)); - - this.properties = ValidateProperties(properties); - } - - protected override Field UpdateInternal(FieldProperties newProperties) - { - var typedProperties = ValidateProperties(newProperties); - - return Clone>(clone => clone.properties = typedProperties); - } - - private T ValidateProperties(FieldProperties newProperties) - { - Guard.NotNull(newProperties, nameof(newProperties)); - - newProperties.Freeze(); - - if (!(newProperties is T typedProperties)) - { - throw new ArgumentException($"Properties must be of type '{typeof(T)}", nameof(newProperties)); - } - - return typedProperties; - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/GeolocationField.cs b/src/Squidex.Domain.Apps.Core/Schemas/GeolocationField.cs deleted file mode 100644 index 8e242f3fc..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/GeolocationField.cs +++ /dev/null @@ -1,63 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Generic; -using Newtonsoft.Json.Linq; -using Squidex.Domain.Apps.Core.Schemas.Validators; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public sealed class GeolocationField : Field - { - public GeolocationField(long id, string name, Partitioning partitioning) - : this(id, name, partitioning, new GeolocationFieldProperties()) - { - } - - public GeolocationField(long id, string name, Partitioning partitioning, GeolocationFieldProperties properties) - : base(id, name, partitioning, properties) - { - } - - protected override IEnumerable CreateValidators() - { - if (Properties.IsRequired) - { - yield return new RequiredValidator(); - } - } - - public override object ConvertValue(JToken value) - { - var geolocation = (JObject)value; - - foreach (var property in geolocation.Properties()) - { - if (!string.Equals(property.Name, "latitude", StringComparison.OrdinalIgnoreCase) && - !string.Equals(property.Name, "longitude", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidCastException("Geolocation can only have latitude and longitude property."); - } - } - - var lat = (double)geolocation["latitude"]; - var lon = (double)geolocation["longitude"]; - - Guard.Between(lat, -90, 90, "latitude"); - Guard.Between(lon, -180, 180, "longitude"); - - return value; - } - - public override T Accept(IFieldVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/GeolocationFieldEditor.cs b/src/Squidex.Domain.Apps.Core/Schemas/GeolocationFieldEditor.cs deleted file mode 100644 index 6a1b70099..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/GeolocationFieldEditor.cs +++ /dev/null @@ -1,14 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public enum GeolocationFieldEditor - { - Map - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/GeolocationFieldProperties.cs b/src/Squidex.Domain.Apps.Core/Schemas/GeolocationFieldProperties.cs deleted file mode 100644 index 6ba7b7c20..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/GeolocationFieldProperties.cs +++ /dev/null @@ -1,42 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Newtonsoft.Json.Linq; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core.Schemas -{ - [TypeName(nameof(GeolocationField))] - public sealed class GeolocationFieldProperties : FieldProperties - { - private GeolocationFieldEditor editor; - - public GeolocationFieldEditor Editor - { - get - { - return editor; - } - set - { - ThrowIfFrozen(); - - editor = value; - } - } - - public override JToken GetDefaultValue() - { - return null; - } - - public override T Accept(IFieldPropertiesVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/IFieldPropertiesVisitor.cs b/src/Squidex.Domain.Apps.Core/Schemas/IFieldPropertiesVisitor.cs deleted file mode 100644 index 108269359..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/IFieldPropertiesVisitor.cs +++ /dev/null @@ -1,30 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public interface IFieldPropertiesVisitor - { - T Visit(AssetsFieldProperties properties); - - T Visit(BooleanFieldProperties properties); - - T Visit(DateTimeFieldProperties properties); - - T Visit(GeolocationFieldProperties properties); - - T Visit(JsonFieldProperties properties); - - T Visit(NumberFieldProperties properties); - - T Visit(ReferencesFieldProperties properties); - - T Visit(StringFieldProperties properties); - - T Visit(TagsFieldProperties properties); - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/IFieldVisitor.cs b/src/Squidex.Domain.Apps.Core/Schemas/IFieldVisitor.cs deleted file mode 100644 index fd4ce2589..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/IFieldVisitor.cs +++ /dev/null @@ -1,30 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public interface IFieldVisitor - { - T Visit(AssetsField field); - - T Visit(BooleanField field); - - T Visit(DateTimeField field); - - T Visit(GeolocationField field); - - T Visit(JsonField field); - - T Visit(NumberField field); - - T Visit(ReferencesField field); - - T Visit(StringField field); - - T Visit(TagsField field); - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/IReferenceField.cs b/src/Squidex.Domain.Apps.Core/Schemas/IReferenceField.cs deleted file mode 100644 index 1859c3a1f..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/IReferenceField.cs +++ /dev/null @@ -1,20 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Generic; -using Newtonsoft.Json.Linq; - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public interface IReferenceField - { - IEnumerable GetReferencedIds(JToken value); - - JToken RemoveDeletedReferences(JToken value, ISet deletedReferencedIds); - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/Json/JsonFieldModel.cs b/src/Squidex.Domain.Apps.Core/Schemas/Json/JsonFieldModel.cs deleted file mode 100644 index e4c04fdf8..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/Json/JsonFieldModel.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 Newtonsoft.Json; - -namespace Squidex.Domain.Apps.Core.Schemas.Json -{ - public sealed class JsonFieldModel - { - [JsonProperty] - public long Id { get; set; } - - [JsonProperty] - public bool IsHidden { get; set; } - - [JsonProperty] - public bool IsLocked { get; set; } - - [JsonProperty] - public bool IsDisabled { get; set; } - - [JsonProperty] - public string Name { get; set; } - - [JsonProperty] - public string Partitioning { get; set; } - - [JsonProperty] - public FieldProperties Properties { get; set; } - } -} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core/Schemas/Json/JsonSchemaModel.cs b/src/Squidex.Domain.Apps.Core/Schemas/Json/JsonSchemaModel.cs deleted file mode 100644 index a4d0428a9..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/Json/JsonSchemaModel.cs +++ /dev/null @@ -1,86 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using Newtonsoft.Json; - -namespace Squidex.Domain.Apps.Core.Schemas.Json -{ - public sealed class JsonSchemaModel - { - [JsonProperty] - public string Name { get; set; } - - [JsonProperty] - public bool IsPublished { get; set; } - - [JsonProperty] - public SchemaProperties Properties { get; set; } - - [JsonProperty] - public List Fields { get; set; } - - public JsonSchemaModel() - { - } - - public JsonSchemaModel(Schema schema) - { - Name = schema.Name; - - Properties = schema.Properties; - - Fields = - schema.Fields?.Select(x => - new JsonFieldModel - { - Id = x.Id, - Name = x.Name, - IsHidden = x.IsHidden, - IsLocked = x.IsLocked, - IsDisabled = x.IsDisabled, - Partitioning = x.Partitioning.Key, - Properties = x.RawProperties - }).ToList(); - - IsPublished = schema.IsPublished; - } - - public Schema ToSchema(FieldRegistry fieldRegistry) - { - var fields = Fields?.Select(fieldModel => - { - var parititonKey = new Partitioning(fieldModel.Partitioning); - - var field = fieldRegistry.CreateField(fieldModel.Id, fieldModel.Name, parititonKey, fieldModel.Properties); - - if (fieldModel.IsDisabled) - { - field = field.Disable(); - } - - if (fieldModel.IsLocked) - { - field = field.Lock(); - } - - if (fieldModel.IsHidden) - { - field = field.Hide(); - } - - return field; - }).ToImmutableList() ?? ImmutableList.Empty; - - var schema = new Schema(Name, IsPublished, Properties, fields); - - return schema; - } - } -} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core/Schemas/Json/SchemaConverter.cs b/src/Squidex.Domain.Apps.Core/Schemas/Json/SchemaConverter.cs deleted file mode 100644 index 92aa2ccf3..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/Json/SchemaConverter.cs +++ /dev/null @@ -1,40 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using Newtonsoft.Json; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core.Schemas.Json -{ - public sealed class SchemaConverter : JsonConverter - { - private readonly FieldRegistry fieldRegistry; - - public SchemaConverter(FieldRegistry fieldRegistry) - { - Guard.NotNull(fieldRegistry, nameof(fieldRegistry)); - - this.fieldRegistry = fieldRegistry; - } - - public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) - { - serializer.Serialize(writer, new JsonSchemaModel((Schema)value)); - } - - public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) - { - return serializer.Deserialize(reader).ToSchema(fieldRegistry); - } - - public override bool CanConvert(Type objectType) - { - return objectType == typeof(Schema); - } - } -} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core/Schemas/JsonField.cs b/src/Squidex.Domain.Apps.Core/Schemas/JsonField.cs deleted file mode 100644 index d603f7ec1..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/JsonField.cs +++ /dev/null @@ -1,44 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Collections.Generic; -using Newtonsoft.Json.Linq; -using Squidex.Domain.Apps.Core.Schemas.Validators; - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public sealed class JsonField : Field - { - public JsonField(long id, string name, Partitioning partitioning) - : this(id, name, partitioning, new JsonFieldProperties()) - { - } - - public JsonField(long id, string name, Partitioning partitioning, JsonFieldProperties properties) - : base(id, name, partitioning, properties) - { - } - - protected override IEnumerable CreateValidators() - { - if (Properties.IsRequired) - { - yield return new RequiredValidator(); - } - } - - public override object ConvertValue(JToken value) - { - return value; - } - - public override T Accept(IFieldVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/JsonFieldProperties.cs b/src/Squidex.Domain.Apps.Core/Schemas/JsonFieldProperties.cs deleted file mode 100644 index 607286b4a..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/JsonFieldProperties.cs +++ /dev/null @@ -1,26 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Newtonsoft.Json.Linq; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core.Schemas -{ - [TypeName(nameof(JsonField))] - public sealed class JsonFieldProperties : FieldProperties - { - public override JToken GetDefaultValue() - { - return JValue.CreateNull(); - } - - public override T Accept(IFieldPropertiesVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/JsonSchema/ContentSchemaBuilder.cs b/src/Squidex.Domain.Apps.Core/Schemas/JsonSchema/ContentSchemaBuilder.cs deleted file mode 100644 index f7fa85fcd..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/JsonSchema/ContentSchemaBuilder.cs +++ /dev/null @@ -1,55 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using NJsonSchema; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core.Schemas.JsonSchema -{ - public sealed class ContentSchemaBuilder - { - public JsonSchema4 CreateContentSchema(Schema schema, JsonSchema4 dataSchema) - { - Guard.NotNull(schema, nameof(schema)); - Guard.NotNull(dataSchema, nameof(dataSchema)); - - var schemaName = schema.Properties.Label.WithFallback(schema.Name); - - var contentSchema = new JsonSchema4 - { - Properties = - { - ["id"] = CreateProperty($"The id of the {schemaName} content."), - ["data"] = CreateProperty($"The data of the {schemaName}.", dataSchema), - ["version"] = CreateProperty($"The version of the {schemaName}.", JsonObjectType.Number), - ["created"] = CreateProperty($"The date and time when the {schemaName} content has been created.", "date-time"), - ["createdBy"] = CreateProperty($"The user that has created the {schemaName} content."), - ["lastModified"] = CreateProperty($"The date and time when the {schemaName} content has been modified last.", "date-time"), - ["lastModifiedBy"] = CreateProperty($"The user that has updated the {schemaName} content last.") - }, - Type = JsonObjectType.Object - }; - - return contentSchema; - } - - private static JsonProperty CreateProperty(string description, JsonSchema4 dataSchema) - { - return new JsonProperty { Description = description, IsRequired = true, Type = JsonObjectType.Object, Reference = dataSchema }; - } - - private static JsonProperty CreateProperty(string description, JsonObjectType type) - { - return new JsonProperty { Description = description, IsRequired = true, Type = type }; - } - - private static JsonProperty CreateProperty(string description, string format = null) - { - return new JsonProperty { Description = description, Format = format, IsRequired = true, Type = JsonObjectType.String }; - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/JsonSchema/JsonSchemaExtensions.cs b/src/Squidex.Domain.Apps.Core/Schemas/JsonSchema/JsonSchemaExtensions.cs deleted file mode 100644 index 437983c5c..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/JsonSchema/JsonSchemaExtensions.cs +++ /dev/null @@ -1,70 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Linq; -using NJsonSchema; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core.Schemas.JsonSchema -{ - public static class JsonSchemaExtensions - { - public static JsonSchema4 BuildJsonSchema(this Schema schema, PartitionResolver partitionResolver, Func schemaResolver) - { - Guard.NotNull(schemaResolver, nameof(schemaResolver)); - Guard.NotNull(partitionResolver, nameof(partitionResolver)); - - var schemaName = schema.Name.ToPascalCase(); - - var jsonTypeVisitor = new JsonTypeVisitor(schemaResolver); - var jsonSchema = new JsonSchema4 { Type = JsonObjectType.Object }; - - foreach (var field in schema.Fields.Where(x => !x.IsHidden)) - { - var partitionProperty = CreateProperty(field); - var partitionObject = new JsonSchema4 { Type = JsonObjectType.Object, AllowAdditionalProperties = false }; - var partition = partitionResolver(field.Partitioning); - - foreach (var partitionItem in partition) - { - var partitionItemProperty = field.Accept(jsonTypeVisitor); - - partitionItemProperty.Description = partitionItem.Name; - partitionObject.Properties.Add(partitionItem.Key, partitionItemProperty); - } - - partitionProperty.Reference = schemaResolver($"{schemaName}{field.Name.ToPascalCase()}Property", partitionObject); - - jsonSchema.Properties.Add(field.Name, partitionProperty); - } - - return jsonSchema; - } - - public static JsonProperty CreateProperty(Field field) - { - var jsonProperty = new JsonProperty { IsRequired = field.RawProperties.IsRequired, Type = JsonObjectType.Object }; - - if (!string.IsNullOrWhiteSpace(field.RawProperties.Hints)) - { - jsonProperty.Description = field.RawProperties.Hints; - } - else - { - jsonProperty.Description = field.Name; - } - - if (!string.IsNullOrWhiteSpace(field.RawProperties.Hints)) - { - jsonProperty.Description += $" ({field.RawProperties.Hints})."; - } - - return jsonProperty; - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/JsonSchema/JsonTypeVisitor.cs b/src/Squidex.Domain.Apps.Core/Schemas/JsonSchema/JsonTypeVisitor.cs deleted file mode 100644 index 1a68defea..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/JsonSchema/JsonTypeVisitor.cs +++ /dev/null @@ -1,161 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.ObjectModel; -using NJsonSchema; - -namespace Squidex.Domain.Apps.Core.Schemas.JsonSchema -{ - public sealed class JsonTypeVisitor : IFieldVisitor - { - private readonly Func schemaResolver; - - public JsonTypeVisitor(Func schemaResolver) - { - this.schemaResolver = schemaResolver; - } - - public JsonProperty Visit(AssetsField field) - { - return CreateProperty(field, jsonProperty => - { - var itemSchema = schemaResolver("AssetItem", new JsonSchema4 { Type = JsonObjectType.String }); - - jsonProperty.Type = JsonObjectType.Array; - jsonProperty.Item = itemSchema; - }); - } - - public JsonProperty Visit(BooleanField field) - { - return CreateProperty(field, jsonProperty => - { - jsonProperty.Type = JsonObjectType.Boolean; - }); - } - - public JsonProperty Visit(DateTimeField field) - { - return CreateProperty(field, jsonProperty => - { - jsonProperty.Type = JsonObjectType.String; - jsonProperty.Format = JsonFormatStrings.DateTime; - }); - } - - public JsonProperty Visit(GeolocationField field) - { - return CreateProperty(field, jsonProperty => - { - var geolocationSchema = new JsonSchema4 - { - AllowAdditionalProperties = false - }; - - geolocationSchema.Properties.Add("latitude", new JsonProperty - { - Type = JsonObjectType.Number, - Minimum = -90, - Maximum = 90, - IsRequired = true - }); - - geolocationSchema.Properties.Add("longitude", new JsonProperty - { - Type = JsonObjectType.Number, - Minimum = -180, - Maximum = 180, - IsRequired = true - }); - - var schemaReference = schemaResolver("GeolocationDto", geolocationSchema); - - jsonProperty.Type = JsonObjectType.Object; - jsonProperty.Reference = schemaReference; - }); - } - - public JsonProperty Visit(JsonField field) - { - return CreateProperty(field, jsonProperty => - { - jsonProperty.Type = JsonObjectType.Object; - }); - } - - public JsonProperty Visit(NumberField field) - { - return CreateProperty(field, jsonProperty => - { - jsonProperty.Type = JsonObjectType.Number; - - if (field.Properties.MinValue.HasValue) - { - jsonProperty.Minimum = (decimal)field.Properties.MinValue.Value; - } - - if (field.Properties.MaxValue.HasValue) - { - jsonProperty.Maximum = (decimal)field.Properties.MaxValue.Value; - } - }); - } - - public JsonProperty Visit(ReferencesField field) - { - return CreateProperty(field, jsonProperty => - { - var itemSchema = schemaResolver("ReferenceItem", new JsonSchema4 { Type = JsonObjectType.String }); - - jsonProperty.Type = JsonObjectType.Array; - jsonProperty.Item = itemSchema; - }); - } - - public JsonProperty Visit(StringField field) - { - return CreateProperty(field, jsonProperty => - { - jsonProperty.Type = JsonObjectType.String; - - jsonProperty.MinLength = field.Properties.MinLength; - jsonProperty.MaxLength = field.Properties.MaxLength; - - if (field.Properties.AllowedValues != null) - { - var names = jsonProperty.EnumerationNames = jsonProperty.EnumerationNames ?? new Collection(); - - foreach (var value in field.Properties.AllowedValues) - { - names.Add(value); - } - } - }); - } - - public JsonProperty Visit(TagsField field) - { - return CreateProperty(field, jsonProperty => - { - var itemSchema = schemaResolver("TagsItem", new JsonSchema4 { Type = JsonObjectType.String }); - - jsonProperty.Type = JsonObjectType.Array; - jsonProperty.Item = itemSchema; - }); - } - - private static JsonProperty CreateProperty(Field field, Action updater) - { - var property = new JsonProperty { IsRequired = field.RawProperties.IsRequired }; - - updater(property); - - return property; - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/NamedElementPropertiesBase.cs b/src/Squidex.Domain.Apps.Core/Schemas/NamedElementPropertiesBase.cs deleted file mode 100644 index c5d667baf..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/NamedElementPropertiesBase.cs +++ /dev/null @@ -1,60 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public abstract class NamedElementPropertiesBase - { - private string label; - private string hints; - - protected bool IsFrozen { get; private set; } - - public string Label - { - get - { - return label; - } - set - { - ThrowIfFrozen(); - - label = value; - } - } - - public string Hints - { - get - { - return hints; - } - set - { - ThrowIfFrozen(); - - hints = value; - } - } - - protected void ThrowIfFrozen() - { - if (IsFrozen) - { - throw new InvalidOperationException("Object is frozen."); - } - } - - public void Freeze() - { - IsFrozen = true; - } - } -} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core/Schemas/NumberField.cs b/src/Squidex.Domain.Apps.Core/Schemas/NumberField.cs deleted file mode 100644 index a56ecca26..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/NumberField.cs +++ /dev/null @@ -1,55 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Collections.Generic; -using System.Linq; -using Newtonsoft.Json.Linq; -using Squidex.Domain.Apps.Core.Schemas.Validators; - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public sealed class NumberField : Field - { - public NumberField(long id, string name, Partitioning partitioning) - : this(id, name, partitioning, new NumberFieldProperties()) - { - } - - public NumberField(long id, string name, Partitioning partitioning, NumberFieldProperties properties) - : base(id, name, partitioning, properties) - { - } - - protected override IEnumerable CreateValidators() - { - if (Properties.IsRequired) - { - yield return new RequiredValidator(); - } - - if (Properties.MinValue.HasValue || Properties.MaxValue.HasValue) - { - yield return new RangeValidator(Properties.MinValue, Properties.MaxValue); - } - - if (Properties.AllowedValues != null) - { - yield return new AllowedValuesValidator(Properties.AllowedValues.ToArray()); - } - } - - public override T Accept(IFieldVisitor visitor) - { - return visitor.Visit(this); - } - - public override object ConvertValue(JToken value) - { - return (double?)value; - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/NumberFieldEditor.cs b/src/Squidex.Domain.Apps.Core/Schemas/NumberFieldEditor.cs deleted file mode 100644 index 2562a734f..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/NumberFieldEditor.cs +++ /dev/null @@ -1,17 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public enum NumberFieldEditor - { - Input, - Radio, - Dropdown, - Stars - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/NumberFieldProperties.cs b/src/Squidex.Domain.Apps.Core/Schemas/NumberFieldProperties.cs deleted file mode 100644 index fe6e53f28..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/NumberFieldProperties.cs +++ /dev/null @@ -1,103 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Collections.Immutable; -using Newtonsoft.Json.Linq; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core.Schemas -{ - [TypeName(nameof(NumberField))] - public sealed class NumberFieldProperties : FieldProperties - { - private double? maxValue; - private double? minValue; - private double? defaultValue; - private ImmutableList allowedValues; - private NumberFieldEditor editor; - - public double? MaxValue - { - get - { - return maxValue; - } - set - { - ThrowIfFrozen(); - - maxValue = value; - } - } - - public double? MinValue - { - get - { - return minValue; - } - set - { - ThrowIfFrozen(); - - minValue = value; - } - } - - public double? DefaultValue - { - get - { - return defaultValue; - } - set - { - ThrowIfFrozen(); - - defaultValue = value; - } - } - - public ImmutableList AllowedValues - { - get - { - return allowedValues; - } - set - { - ThrowIfFrozen(); - - allowedValues = value; - } - } - - public NumberFieldEditor Editor - { - get - { - return editor; - } - set - { - ThrowIfFrozen(); - - editor = value; - } - } - - public override JToken GetDefaultValue() - { - return DefaultValue; - } - - public override T Accept(IFieldPropertiesVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/ReferencesField.cs b/src/Squidex.Domain.Apps.Core/Schemas/ReferencesField.cs deleted file mode 100644 index 9e4b755c7..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/ReferencesField.cs +++ /dev/null @@ -1,87 +0,0 @@ -// ========================================================================== -// 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.Collections.Immutable; -using System.Linq; -using Newtonsoft.Json.Linq; -using Squidex.Domain.Apps.Core.Schemas.Validators; - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public sealed class ReferencesField : Field, IReferenceField - { - private static readonly ImmutableList EmptyIds = ImmutableList.Empty; - - public ReferencesField(long id, string name, Partitioning partitioning) - : this(id, name, partitioning, new ReferencesFieldProperties()) - { - } - - public ReferencesField(long id, string name, Partitioning partitioning, ReferencesFieldProperties properties) - : base(id, name, partitioning, properties) - { - } - - protected override IEnumerable CreateValidators() - { - if (Properties.IsRequired || Properties.MinItems.HasValue || Properties.MaxItems.HasValue) - { - yield return new CollectionValidator(Properties.IsRequired, Properties.MinItems, Properties.MaxItems); - } - - if (Properties.SchemaId != Guid.Empty) - { - yield return new ReferencesValidator(Properties.SchemaId); - } - } - - public IEnumerable GetReferencedIds(JToken value) - { - IEnumerable result = null; - try - { - result = value?.ToObject>(); - } - catch - { - result = EmptyIds; - } - - return (result ?? EmptyIds).Union(new[] { Properties.SchemaId }); - } - - public JToken RemoveDeletedReferences(JToken value, ISet deletedReferencedIds) - { - if (value == null || value.Type == JTokenType.Null) - { - return null; - } - - if (deletedReferencedIds.Contains(Properties.SchemaId)) - { - return new JArray(); - } - - var oldReferenceIds = GetReferencedIds(value).TakeWhile(x => x != Properties.SchemaId).ToArray(); - var newReferenceIds = oldReferenceIds.Where(x => !deletedReferencedIds.Contains(x)).ToList(); - - return newReferenceIds.Count != oldReferenceIds.Length ? JToken.FromObject(newReferenceIds) : value; - } - - public override object ConvertValue(JToken value) - { - return value.ToObject>(); - } - - public override T Accept(IFieldVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/ReferencesFieldProperties.cs b/src/Squidex.Domain.Apps.Core/Schemas/ReferencesFieldProperties.cs deleted file mode 100644 index 7cd89ddf1..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/ReferencesFieldProperties.cs +++ /dev/null @@ -1,73 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using Newtonsoft.Json.Linq; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core.Schemas -{ - [TypeName(nameof(ReferencesField))] - public sealed class ReferencesFieldProperties : FieldProperties - { - private int? minItems; - private int? maxItems; - private Guid schemaId; - - public int? MinItems - { - get - { - return minItems; - } - set - { - ThrowIfFrozen(); - - minItems = value; - } - } - - public int? MaxItems - { - get - { - return maxItems; - } - set - { - ThrowIfFrozen(); - - maxItems = value; - } - } - - public Guid SchemaId - { - get - { - return schemaId; - } - set - { - ThrowIfFrozen(); - - schemaId = value; - } - } - - public override JToken GetDefaultValue() - { - return new JArray(); - } - - public override T Accept(IFieldPropertiesVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/Schema.cs b/src/Squidex.Domain.Apps.Core/Schemas/Schema.cs deleted file mode 100644 index b8a200b5b..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/Schema.cs +++ /dev/null @@ -1,170 +0,0 @@ -// ========================================================================== -// 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.Collections.Immutable; -using System.Linq; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public sealed class Schema - { - private readonly string name; - private readonly SchemaProperties properties; - private readonly ImmutableList fields; - private readonly ImmutableDictionary fieldsById; - private readonly ImmutableDictionary fieldsByName; - private readonly bool isPublished; - - public string Name - { - get { return name; } - } - - public bool IsPublished - { - get { return isPublished; } - } - - public ImmutableList Fields - { - get { return fields; } - } - - public ImmutableDictionary FieldsById - { - get { return fieldsById; } - } - - public ImmutableDictionary FieldsByName - { - get { return fieldsByName; } - } - - public SchemaProperties Properties - { - get { return properties; } - } - - public Schema(string name, bool isPublished, SchemaProperties properties, ImmutableList fields) - { - Guard.NotNull(fields, nameof(fields)); - Guard.NotNull(properties, nameof(properties)); - Guard.NotNullOrEmpty(name, nameof(name)); - - fieldsById = fields.ToImmutableDictionary(x => x.Id); - fieldsByName = fields.ToImmutableDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase); - - this.name = name; - - this.fields = fields; - - this.properties = properties; - this.properties.Freeze(); - - this.isPublished = isPublished; - } - - public static Schema Create(string name, SchemaProperties newProperties) - { - return new Schema(name, false, newProperties, ImmutableList.Empty); - } - - public Schema Update(SchemaProperties newProperties) - { - Guard.NotNull(newProperties, nameof(newProperties)); - - return new Schema(name, isPublished, newProperties, fields); - } - - public Schema UpdateField(long fieldId, FieldProperties newProperties) - { - return UpdateField(fieldId, field => field.Update(newProperties)); - } - - public Schema LockField(long fieldId) - { - return UpdateField(fieldId, field => field.Lock()); - } - - public Schema DisableField(long fieldId) - { - return UpdateField(fieldId, field => field.Disable()); - } - - public Schema EnableField(long fieldId) - { - return UpdateField(fieldId, field => field.Enable()); - } - - public Schema HideField(long fieldId) - { - return UpdateField(fieldId, field => field.Hide()); - } - - public Schema ShowField(long fieldId) - { - return UpdateField(fieldId, field => field.Show()); - } - - public Schema Publish() - { - return new Schema(name, true, properties, fields); - } - - public Schema Unpublish() - { - return new Schema(name, false, properties, fields); - } - - public Schema DeleteField(long fieldId) - { - var newFields = fields.Where(x => x.Id != fieldId).ToImmutableList(); - - return new Schema(name, isPublished, properties, newFields); - } - - public Schema UpdateField(long fieldId, Func updater) - { - Guard.NotNull(updater, nameof(updater)); - - var newFields = fields.Select(f => f.Id == fieldId ? updater(f) ?? f : f).ToImmutableList(); - - return new Schema(name, isPublished, properties, newFields); - } - - public Schema ReorderFields(List ids) - { - Guard.NotNull(ids, nameof(ids)); - - if (ids.Count != fields.Count || ids.Any(x => !fieldsById.ContainsKey(x))) - { - throw new ArgumentException("Ids must cover all fields.", nameof(ids)); - } - - var newFields = fields.OrderBy(f => ids.IndexOf(f.Id)).ToImmutableList(); - - return new Schema(name, isPublished, properties, newFields); - } - - public Schema AddField(Field field) - { - Guard.NotNull(field, nameof(field)); - - if (fieldsByName.ContainsKey(field.Name) || fieldsById.ContainsKey(field.Id)) - { - throw new ArgumentException($"A field with name '{field.Name}' already exists.", nameof(field)); - } - - var newFields = fields.Add(field); - - return new Schema(name, isPublished, properties, newFields); - } - } -} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core/Schemas/SchemaProperties.cs b/src/Squidex.Domain.Apps.Core/Schemas/SchemaProperties.cs deleted file mode 100644 index eebe181e1..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/SchemaProperties.cs +++ /dev/null @@ -1,13 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public sealed class SchemaProperties : NamedElementPropertiesBase - { - } -} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core/Schemas/StringField.cs b/src/Squidex.Domain.Apps.Core/Schemas/StringField.cs deleted file mode 100644 index 972ea2835..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/StringField.cs +++ /dev/null @@ -1,60 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Collections.Generic; -using System.Linq; -using Newtonsoft.Json.Linq; -using Squidex.Domain.Apps.Core.Schemas.Validators; - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public sealed class StringField : Field - { - public StringField(long id, string name, Partitioning partitioning) - : this(id, name, partitioning, new StringFieldProperties()) - { - } - - public StringField(long id, string name, Partitioning partitioning, StringFieldProperties properties) - : base(id, name, partitioning, properties) - { - } - - protected override IEnumerable CreateValidators() - { - if (Properties.IsRequired) - { - yield return new RequiredStringValidator(); - } - - if (Properties.MinLength.HasValue || Properties.MaxLength.HasValue) - { - yield return new StringLengthValidator(Properties.MinLength, Properties.MaxLength); - } - - if (!string.IsNullOrWhiteSpace(Properties.Pattern)) - { - yield return new PatternValidator(Properties.Pattern, Properties.PatternMessage); - } - - if (Properties.AllowedValues != null) - { - yield return new AllowedValuesValidator(Properties.AllowedValues.ToArray()); - } - } - - public override object ConvertValue(JToken value) - { - return value.ToString(); - } - - public override T Accept(IFieldVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/StringFieldEditor.cs b/src/Squidex.Domain.Apps.Core/Schemas/StringFieldEditor.cs deleted file mode 100644 index cdb515d5d..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/StringFieldEditor.cs +++ /dev/null @@ -1,19 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public enum StringFieldEditor - { - Input, - Markdown, - Dropdown, - Radio, - RichText, - TextArea - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/StringFieldProperties.cs b/src/Squidex.Domain.Apps.Core/Schemas/StringFieldProperties.cs deleted file mode 100644 index daaea426f..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/StringFieldProperties.cs +++ /dev/null @@ -1,139 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Collections.Immutable; -using Newtonsoft.Json.Linq; -using Squidex.Infrastructure; -using Squidex.Infrastructure.Json; - -namespace Squidex.Domain.Apps.Core.Schemas -{ - [TypeName(nameof(StringField))] - public sealed class StringFieldProperties : FieldProperties - { - private int? minLength; - private int? maxLength; - private string pattern; - private string patternMessage; - private string defaultValue; - private ImmutableList allowedValues; - private StringFieldEditor editor; - - public int? MinLength - { - get - { - return minLength; - } - set - { - ThrowIfFrozen(); - - minLength = value; - } - } - - public int? MaxLength - { - get - { - return maxLength; - } - set - { - ThrowIfFrozen(); - - maxLength = value; - } - } - - public string DefaultValue - { - get - { - return defaultValue; - } - set - { - ThrowIfFrozen(); - - defaultValue = value; - } - } - - public string Pattern - { - get - { - return pattern; - } - set - { - ThrowIfFrozen(); - - pattern = value; - } - } - - public string PatternMessage - { - get - { - return patternMessage; - } - set - { - ThrowIfFrozen(); - - patternMessage = value; - } - } - - public ImmutableList AllowedValues - { - get - { - return allowedValues; - } - set - { - ThrowIfFrozen(); - - allowedValues = value; - } - } - - public StringFieldEditor Editor - { - get - { - return editor; - } - set - { - ThrowIfFrozen(); - - editor = value; - } - } - - public override JToken GetDefaultValue() - { - return DefaultValue; - } - - public override bool ShouldApplyDefaultValue(JToken value) - { - return value.IsNull() || (value is JValue jValue && Equals(jValue.Value, string.Empty)); - } - - public override T Accept(IFieldPropertiesVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/TagsField.cs b/src/Squidex.Domain.Apps.Core/Schemas/TagsField.cs deleted file mode 100644 index c225a8be9..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/TagsField.cs +++ /dev/null @@ -1,49 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Collections.Generic; -using System.Collections.Immutable; -using Newtonsoft.Json.Linq; -using Squidex.Domain.Apps.Core.Schemas.Validators; - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public sealed class TagsField : Field - { - private static readonly ImmutableList EmptyTags = ImmutableList.Empty; - - public TagsField(long id, string name, Partitioning partitioning) - : this(id, name, partitioning, new TagsFieldProperties()) - { - } - - public TagsField(long id, string name, Partitioning partitioning, TagsFieldProperties properties) - : base(id, name, partitioning, properties) - { - } - - protected override IEnumerable CreateValidators() - { - if (Properties.IsRequired || Properties.MinItems.HasValue || Properties.MaxItems.HasValue) - { - yield return new CollectionValidator(Properties.IsRequired, Properties.MinItems, Properties.MaxItems); - } - - yield return new CollectionItemValidator(new RequiredStringValidator()); - } - - public override object ConvertValue(JToken value) - { - return value.ToObject>(); - } - - public override T Accept(IFieldVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/TagsFieldProperties.cs b/src/Squidex.Domain.Apps.Core/Schemas/TagsFieldProperties.cs deleted file mode 100644 index 4a01f28d0..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/TagsFieldProperties.cs +++ /dev/null @@ -1,57 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Newtonsoft.Json.Linq; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core.Schemas -{ - [TypeName(nameof(TagsField))] - public sealed class TagsFieldProperties : FieldProperties - { - private int? minItems; - private int? maxItems; - - public int? MinItems - { - get - { - return minItems; - } - set - { - ThrowIfFrozen(); - - minItems = value; - } - } - - public int? MaxItems - { - get - { - return maxItems; - } - set - { - ThrowIfFrozen(); - - maxItems = value; - } - } - - public override JToken GetDefaultValue() - { - return new JArray(); - } - - public override T Accept(IFieldPropertiesVisitor visitor) - { - return visitor.Visit(this); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/ValidationContext.cs b/src/Squidex.Domain.Apps.Core/Schemas/ValidationContext.cs deleted file mode 100644 index e356d3614..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/ValidationContext.cs +++ /dev/null @@ -1,58 +0,0 @@ -// ========================================================================== -// 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.Threading.Tasks; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core.Schemas -{ - public sealed class ValidationContext - { - private readonly Func, Guid, Task>> checkContent; - private readonly Func, Task>> checkAsset; - - public bool IsOptional { get; } - - public ValidationContext( - Func, Guid, Task>> checkContent, - Func, Task>> checkAsset) - : this(checkContent, checkAsset, false) - { - } - - private ValidationContext( - Func, Guid, Task>> checkContent, - Func, Task>> checkAsset, - bool isOptional) - { - Guard.NotNull(checkAsset, nameof(checkAsset)); - Guard.NotNull(checkContent, nameof(checkAsset)); - - this.checkContent = checkContent; - this.checkAsset = checkAsset; - - IsOptional = isOptional; - } - - public ValidationContext Optional(bool isOptional) - { - return isOptional == IsOptional ? this : new ValidationContext(checkContent, checkAsset, isOptional); - } - - public Task> GetInvalidContentIdsAsync(IEnumerable contentIds, Guid schemaId) - { - return checkContent(contentIds, schemaId); - } - - public Task> GetInvalidAssetIdsAsync(IEnumerable assetId) - { - return checkAsset(assetId); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/Validators/AllowedValuesValidator.cs b/src/Squidex.Domain.Apps.Core/Schemas/Validators/AllowedValuesValidator.cs deleted file mode 100644 index 4cacb5f39..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/Validators/AllowedValuesValidator.cs +++ /dev/null @@ -1,44 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Linq; -using System.Threading.Tasks; -using Squidex.Infrastructure; -using Squidex.Infrastructure.Tasks; - -namespace Squidex.Domain.Apps.Core.Schemas.Validators -{ - public sealed class AllowedValuesValidator : IValidator - { - private readonly T[] allowedValues; - - public AllowedValuesValidator(params T[] allowedValues) - { - Guard.NotNull(allowedValues, nameof(allowedValues)); - - this.allowedValues = allowedValues; - } - - public Task ValidateAsync(object value, ValidationContext context, Action addError) - { - if (value == null) - { - return TaskHelper.Done; - } - - var typedValue = (T)value; - - if (!allowedValues.Contains(typedValue)) - { - addError(" is not an allowed value."); - } - - return TaskHelper.Done; - } - } -} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core/Schemas/Validators/AssetsValidator.cs b/src/Squidex.Domain.Apps.Core/Schemas/Validators/AssetsValidator.cs deleted file mode 100644 index ec843f437..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/Validators/AssetsValidator.cs +++ /dev/null @@ -1,29 +0,0 @@ -// ========================================================================== -// 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.Threading.Tasks; - -namespace Squidex.Domain.Apps.Core.Schemas.Validators -{ - public sealed class AssetsValidator : IValidator - { - public async Task ValidateAsync(object value, ValidationContext context, Action addError) - { - if (value is ICollection assetIds) - { - var invalidIds = await context.GetInvalidAssetIdsAsync(assetIds); - - foreach (var invalidId in invalidIds) - { - addError($" contains invalid asset '{invalidId}'."); - } - } - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/Validators/CollectionItemValidator.cs b/src/Squidex.Domain.Apps.Core/Schemas/Validators/CollectionItemValidator.cs deleted file mode 100644 index 7127853c2..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/Validators/CollectionItemValidator.cs +++ /dev/null @@ -1,47 +0,0 @@ -// ========================================================================== -// 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.Threading.Tasks; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core.Schemas.Validators -{ - public sealed class CollectionItemValidator : IValidator - { - private readonly IValidator[] itemValidators; - - public CollectionItemValidator(params IValidator[] itemValidators) - { - Guard.NotNull(itemValidators, nameof(itemValidators)); - Guard.NotEmpty(itemValidators, nameof(itemValidators)); - - this.itemValidators = itemValidators; - } - - public async Task ValidateAsync(object value, ValidationContext context, Action addError) - { - if (value is ICollection items) - { - var innerContext = context.Optional(false); - - var index = 1; - - foreach (var item in items) - { - foreach (var itemValidator in itemValidators) - { - await itemValidator.ValidateAsync(item, innerContext, e => addError(e.Replace("", $" item #{index}"))); - } - - index++; - } - } - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/Validators/CollectionValidator.cs b/src/Squidex.Domain.Apps.Core/Schemas/Validators/CollectionValidator.cs deleted file mode 100644 index e7fc6c9bf..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/Validators/CollectionValidator.cs +++ /dev/null @@ -1,53 +0,0 @@ -// ========================================================================== -// 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.Threading.Tasks; -using Squidex.Infrastructure.Tasks; - -namespace Squidex.Domain.Apps.Core.Schemas.Validators -{ - public sealed class CollectionValidator : IValidator - { - private readonly bool isRequired; - private readonly int? minItems; - private readonly int? maxItems; - - public CollectionValidator(bool isRequired, int? minItems = null, int? maxItems = null) - { - this.isRequired = isRequired; - this.minItems = minItems; - this.maxItems = maxItems; - } - - public Task ValidateAsync(object value, ValidationContext context, Action addError) - { - if (!(value is ICollection items) || items.Count == 0) - { - if (isRequired && !context.IsOptional) - { - addError(" is required."); - } - - return TaskHelper.Done; - } - - if (minItems.HasValue && items.Count < minItems.Value) - { - addError($" must have at least {minItems} item(s)."); - } - - if (maxItems.HasValue && items.Count > maxItems.Value) - { - addError($" must have not more than {maxItems} item(s)."); - } - - return TaskHelper.Done; - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/Validators/IValidator.cs b/src/Squidex.Domain.Apps.Core/Schemas/Validators/IValidator.cs deleted file mode 100644 index 7c115bb2b..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/Validators/IValidator.cs +++ /dev/null @@ -1,17 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Threading.Tasks; - -namespace Squidex.Domain.Apps.Core.Schemas.Validators -{ - public interface IValidator - { - Task ValidateAsync(object value, ValidationContext context, Action addError); - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/Validators/PatternValidator.cs b/src/Squidex.Domain.Apps.Core/Schemas/Validators/PatternValidator.cs deleted file mode 100644 index 6dbde5545..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/Validators/PatternValidator.cs +++ /dev/null @@ -1,47 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using Squidex.Infrastructure.Tasks; - -namespace Squidex.Domain.Apps.Core.Schemas.Validators -{ - public class PatternValidator : IValidator - { - private readonly Regex regex; - private readonly string errorMessage; - - public PatternValidator(string pattern, string errorMessage = null) - { - this.errorMessage = errorMessage; - - regex = new Regex("^" + pattern + "$"); - } - - public Task ValidateAsync(object value, ValidationContext context, Action addError) - { - if (value is string stringValue) - { - if (!string.IsNullOrEmpty(stringValue) && !regex.IsMatch(stringValue)) - { - if (string.IsNullOrWhiteSpace(errorMessage)) - { - addError(" is not valid."); - } - else - { - addError(errorMessage); - } - } - } - - return TaskHelper.Done; - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/Validators/RangeValidator.cs b/src/Squidex.Domain.Apps.Core/Schemas/Validators/RangeValidator.cs deleted file mode 100644 index 20a507fc3..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/Validators/RangeValidator.cs +++ /dev/null @@ -1,52 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Threading.Tasks; -using Squidex.Infrastructure.Tasks; - -namespace Squidex.Domain.Apps.Core.Schemas.Validators -{ - public sealed class RangeValidator : IValidator where T : struct, IComparable - { - private readonly T? min; - private readonly T? max; - - public RangeValidator(T? min, T? max) - { - if (min.HasValue && max.HasValue && min.Value.CompareTo(max.Value) >= 0) - { - throw new ArgumentException("Min value must be greater than max value.", nameof(min)); - } - - this.min = min; - this.max = max; - } - - public Task ValidateAsync(object value, ValidationContext context, Action addError) - { - if (value == null) - { - return TaskHelper.Done; - } - - var typedValue = (T)value; - - if (min.HasValue && typedValue.CompareTo(min.Value) < 0) - { - addError($" must be greater or equals than '{min}'."); - } - - if (max.HasValue && typedValue.CompareTo(max.Value) > 0) - { - addError($" must be less or equals than '{max}'."); - } - - return TaskHelper.Done; - } - } -} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core/Schemas/Validators/ReferencesValidator.cs b/src/Squidex.Domain.Apps.Core/Schemas/Validators/ReferencesValidator.cs deleted file mode 100644 index cfd7b7c27..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/Validators/ReferencesValidator.cs +++ /dev/null @@ -1,36 +0,0 @@ -// ========================================================================== -// 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.Threading.Tasks; - -namespace Squidex.Domain.Apps.Core.Schemas.Validators -{ - public sealed class ReferencesValidator : IValidator - { - private readonly Guid schemaId; - - public ReferencesValidator(Guid schemaId) - { - this.schemaId = schemaId; - } - - public async Task ValidateAsync(object value, ValidationContext context, Action addError) - { - if (value is ICollection contentIds) - { - var invalidIds = await context.GetInvalidContentIdsAsync(contentIds, schemaId); - - foreach (var invalidId in invalidIds) - { - addError($" contains invalid reference '{invalidId}'."); - } - } - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/Validators/RequiredStringValidator.cs b/src/Squidex.Domain.Apps.Core/Schemas/Validators/RequiredStringValidator.cs deleted file mode 100644 index e77105b2c..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/Validators/RequiredStringValidator.cs +++ /dev/null @@ -1,40 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Threading.Tasks; -using Squidex.Infrastructure.Tasks; - -namespace Squidex.Domain.Apps.Core.Schemas.Validators -{ - public class RequiredStringValidator : IValidator - { - private readonly bool validateEmptyStrings; - - public RequiredStringValidator(bool validateEmptyStrings = false) - { - this.validateEmptyStrings = validateEmptyStrings; - } - - public Task ValidateAsync(object value, ValidationContext context, Action addError) - { - if (context.IsOptional || (value != null && !(value is string))) - { - return TaskHelper.Done; - } - - var valueAsString = (string)value; - - if (valueAsString == null || (validateEmptyStrings && string.IsNullOrWhiteSpace(valueAsString))) - { - addError(" is required."); - } - - return TaskHelper.Done; - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/Validators/RequiredValidator.cs b/src/Squidex.Domain.Apps.Core/Schemas/Validators/RequiredValidator.cs deleted file mode 100644 index 6a41a6d44..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/Validators/RequiredValidator.cs +++ /dev/null @@ -1,26 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Threading.Tasks; -using Squidex.Infrastructure.Tasks; - -namespace Squidex.Domain.Apps.Core.Schemas.Validators -{ - public class RequiredValidator : IValidator - { - public Task ValidateAsync(object value, ValidationContext context, Action addError) - { - if (value == null && !context.IsOptional) - { - addError(" is required."); - } - - return TaskHelper.Done; - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Schemas/Validators/StringLengthValidator.cs b/src/Squidex.Domain.Apps.Core/Schemas/Validators/StringLengthValidator.cs deleted file mode 100644 index 8e6add029..000000000 --- a/src/Squidex.Domain.Apps.Core/Schemas/Validators/StringLengthValidator.cs +++ /dev/null @@ -1,48 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Threading.Tasks; -using Squidex.Infrastructure.Tasks; - -namespace Squidex.Domain.Apps.Core.Schemas.Validators -{ - public class StringLengthValidator : IValidator - { - private readonly int? minLength; - private readonly int? maxLength; - - public StringLengthValidator(int? minLength, int? maxLength) - { - if (minLength.HasValue && maxLength.HasValue && minLength.Value >= maxLength.Value) - { - throw new ArgumentException("Min length must be greater than max length.", nameof(minLength)); - } - - this.minLength = minLength; - this.maxLength = maxLength; - } - - public Task ValidateAsync(object value, ValidationContext context, Action addError) - { - if (value is string stringValue && !string.IsNullOrEmpty(stringValue)) - { - if (minLength.HasValue && stringValue.Length < minLength.Value) - { - addError($" must have more than '{minLength}' characters."); - } - - if (maxLength.HasValue && stringValue.Length > maxLength.Value) - { - addError($" must have less than '{maxLength}' characters."); - } - } - - return TaskHelper.Done; - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Scripting/ContentWrapper/ContentDataObject.cs b/src/Squidex.Domain.Apps.Core/Scripting/ContentWrapper/ContentDataObject.cs deleted file mode 100644 index 934ee5387..000000000 --- a/src/Squidex.Domain.Apps.Core/Scripting/ContentWrapper/ContentDataObject.cs +++ /dev/null @@ -1,131 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Collections.Generic; -using Jint; -using Jint.Native; -using Jint.Native.Object; -using Jint.Runtime.Descriptors; -using Squidex.Domain.Apps.Core.Contents; -using Squidex.Infrastructure; - -#pragma warning disable RECS0133 // Parameter name differs in base declaration - -namespace Squidex.Domain.Apps.Core.Scripting.ContentWrapper -{ - public sealed class ContentDataObject : ObjectInstance - { - private readonly NamedContentData contentData; - private HashSet fieldsToDelete; - private Dictionary fieldProperties; - private bool isChanged; - - public ContentDataObject(Engine engine, NamedContentData contentData) - : base(engine) - { - Extensible = true; - - this.contentData = contentData; - } - - public void MarkChanged() - { - isChanged = true; - } - - public bool TryUpdate(out NamedContentData result) - { - result = contentData; - - if (isChanged) - { - if (fieldsToDelete != null) - { - foreach (var field in fieldsToDelete) - { - contentData.Remove(field); - } - } - - if (fieldProperties != null) - { - foreach (var kvp in fieldProperties) - { - if (kvp.Value.ContentField.TryUpdate(out var fieldData)) - { - contentData[kvp.Key] = fieldData; - } - } - } - } - - return isChanged; - } - - public override void RemoveOwnProperty(string propertyName) - { - if (fieldsToDelete == null) - { - fieldsToDelete = new HashSet(); - } - - fieldsToDelete.Add(propertyName); - fieldProperties?.Remove(propertyName); - - MarkChanged(); - } - - public override bool DefineOwnProperty(string propertyName, PropertyDescriptor desc, bool throwOnError) - { - EnsurePropertiesInitialized(); - - if (!fieldProperties.ContainsKey(propertyName)) - { - fieldProperties[propertyName] = new ContentDataProperty(this) { Value = desc.Value }; - } - - return true; - } - - public override void Put(string propertyName, JsValue value, bool throwOnError) - { - EnsurePropertiesInitialized(); - - fieldProperties.GetOrAdd(propertyName, x => new ContentDataProperty(this)).Value = value; - } - - public override PropertyDescriptor GetOwnProperty(string propertyName) - { - EnsurePropertiesInitialized(); - - return fieldProperties.GetOrDefault(propertyName) ?? new PropertyDescriptor(new ObjectInstance(Engine) { Extensible = true }, true, false, true); - } - - public override IEnumerable> GetOwnProperties() - { - EnsurePropertiesInitialized(); - - foreach (var property in fieldProperties) - { - yield return new KeyValuePair(property.Key, property.Value); - } - } - - private void EnsurePropertiesInitialized() - { - if (fieldProperties == null) - { - fieldProperties = new Dictionary(contentData.Count); - - foreach (var kvp in contentData) - { - fieldProperties.Add(kvp.Key, new ContentDataProperty(this, new ContentFieldObject(this, kvp.Value, false))); - } - } - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Scripting/ContentWrapper/ContentDataProperty.cs b/src/Squidex.Domain.Apps.Core/Scripting/ContentWrapper/ContentDataProperty.cs deleted file mode 100644 index 943d41240..000000000 --- a/src/Squidex.Domain.Apps.Core/Scripting/ContentWrapper/ContentDataProperty.cs +++ /dev/null @@ -1,67 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Jint.Native; -using Jint.Runtime; -using Jint.Runtime.Descriptors; -using Squidex.Domain.Apps.Core.Contents; - -namespace Squidex.Domain.Apps.Core.Scripting.ContentWrapper -{ - public sealed class ContentDataProperty : PropertyDescriptor - { - private readonly ContentDataObject contentData; - private ContentFieldObject contentField; - private JsValue value; - - public override JsValue Value - { - get - { - return value; - } - set - { - if (!Equals(this.value, value)) - { - if (value == null || !value.IsObject()) - { - throw new JavaScriptException("Can only assign object to content data."); - } - - var obj = value.AsObject(); - - contentField = new ContentFieldObject(contentData, new ContentFieldData(), true); - - foreach (var kvp in obj.GetOwnProperties()) - { - contentField.Put(kvp.Key, kvp.Value.Value, true); - } - - this.value = new JsValue(contentField); - } - } - } - - public ContentFieldObject ContentField - { - get { return contentField; } - } - - public ContentDataProperty(ContentDataObject contentData, ContentFieldObject contentField = null) - : base(null, true, true, true) - { - this.contentData = contentData; - this.contentField = contentField; - - if (contentField != null) - { - value = new JsValue(contentField); - } - } - } -} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core/Scripting/ContentWrapper/ContentFieldObject.cs b/src/Squidex.Domain.Apps.Core/Scripting/ContentWrapper/ContentFieldObject.cs deleted file mode 100644 index f92963096..000000000 --- a/src/Squidex.Domain.Apps.Core/Scripting/ContentWrapper/ContentFieldObject.cs +++ /dev/null @@ -1,136 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Collections.Generic; -using Jint.Native.Object; -using Jint.Runtime.Descriptors; -using Squidex.Domain.Apps.Core.Contents; -using Squidex.Infrastructure; - -#pragma warning disable RECS0133 // Parameter name differs in base declaration - -namespace Squidex.Domain.Apps.Core.Scripting.ContentWrapper -{ - public sealed class ContentFieldObject : ObjectInstance - { - private readonly ContentDataObject contentData; - private readonly ContentFieldData fieldData; - private HashSet valuesToDelete; - private Dictionary valueProperties; - private bool isChanged; - - public ContentFieldData FieldData - { - get { return fieldData; } - } - - public ContentFieldObject(ContentDataObject contentData, ContentFieldData fieldData, bool isNew) - : base(contentData.Engine) - { - Extensible = true; - - this.contentData = contentData; - this.fieldData = fieldData; - - if (isNew) - { - MarkChanged(); - } - } - - public void MarkChanged() - { - isChanged = true; - - contentData.MarkChanged(); - } - - public bool TryUpdate(out ContentFieldData result) - { - result = fieldData; - - if (isChanged) - { - if (valuesToDelete != null) - { - foreach (var field in valuesToDelete) - { - fieldData.Remove(field); - } - } - - if (valueProperties != null) - { - foreach (var kvp in valueProperties) - { - if (kvp.Value.IsChanged) - { - fieldData[kvp.Key] = kvp.Value.ContentValue; - } - } - } - } - - return isChanged; - } - - public override void RemoveOwnProperty(string propertyName) - { - if (valuesToDelete == null) - { - valuesToDelete = new HashSet(); - } - - valuesToDelete.Add(propertyName); - valueProperties?.Remove(propertyName); - - MarkChanged(); - } - - public override bool DefineOwnProperty(string propertyName, PropertyDescriptor desc, bool throwOnError) - { - EnsurePropertiesInitialized(); - - if (!valueProperties.ContainsKey(propertyName)) - { - valueProperties[propertyName] = new ContentFieldProperty(this) { Value = desc.Value }; - } - - return true; - } - - public override PropertyDescriptor GetOwnProperty(string propertyName) - { - EnsurePropertiesInitialized(); - - return valueProperties?.GetOrDefault(propertyName) ?? PropertyDescriptor.Undefined; - } - - public override IEnumerable> GetOwnProperties() - { - EnsurePropertiesInitialized(); - - foreach (var property in valueProperties) - { - yield return new KeyValuePair(property.Key, property.Value); - } - } - - private void EnsurePropertiesInitialized() - { - if (valueProperties == null) - { - valueProperties = new Dictionary(FieldData.Count); - - foreach (var kvp in FieldData) - { - valueProperties.Add(kvp.Key, new ContentFieldProperty(this, kvp.Value)); - } - } - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Scripting/ContentWrapper/ContentFieldProperty.cs b/src/Squidex.Domain.Apps.Core/Scripting/ContentWrapper/ContentFieldProperty.cs deleted file mode 100644 index 11ec3918e..000000000 --- a/src/Squidex.Domain.Apps.Core/Scripting/ContentWrapper/ContentFieldProperty.cs +++ /dev/null @@ -1,58 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Jint.Native; -using Jint.Runtime.Descriptors; -using Newtonsoft.Json.Linq; - -namespace Squidex.Domain.Apps.Core.Scripting.ContentWrapper -{ - public sealed class ContentFieldProperty : PropertyDescriptor - { - private readonly ContentFieldObject contentField; - private JToken contentValue; - private JsValue value; - private bool isChanged; - - public override JsValue Value - { - get - { - return value ?? (value = JsonMapper.Map(contentValue, contentField.Engine)); - } - set - { - if (!Equals(this.value, value)) - { - this.value = value; - - contentValue = null; - contentField.MarkChanged(); - - isChanged = true; - } - } - } - - public JToken ContentValue - { - get { return contentValue ?? (contentValue = JsonMapper.Map(value)); } - } - - public bool IsChanged - { - get { return isChanged; } - } - - public ContentFieldProperty(ContentFieldObject contentField, JToken contentValue = null) - : base(null, true, true, true) - { - this.contentField = contentField; - this.contentValue = contentValue; - } - } -} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core/Scripting/ContentWrapper/JsonMapper.cs b/src/Squidex.Domain.Apps.Core/Scripting/ContentWrapper/JsonMapper.cs deleted file mode 100644 index ea3a19385..000000000 --- a/src/Squidex.Domain.Apps.Core/Scripting/ContentWrapper/JsonMapper.cs +++ /dev/null @@ -1,145 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using Jint; -using Jint.Native; -using Jint.Native.Object; -using Newtonsoft.Json.Linq; - -namespace Squidex.Domain.Apps.Core.Scripting.ContentWrapper -{ - public static class JsonMapper - { - public static JsValue Map(JToken value, Engine engine) - { - if (value == null) - { - return JsValue.Null; - } - - switch (value.Type) - { - case JTokenType.Date: - case JTokenType.Guid: - case JTokenType.String: - case JTokenType.Uri: - case JTokenType.TimeSpan: - return new JsValue((string)value); - case JTokenType.Null: - return JsValue.Null; - case JTokenType.Undefined: - return JsValue.Undefined; - case JTokenType.Integer: - return new JsValue((long)value); - case JTokenType.Float: - return new JsValue((double)value); - case JTokenType.Boolean: - return new JsValue((bool)value); - case JTokenType.Object: - return FromObject(value, engine); - case JTokenType.Array: - { - var arr = (JArray)value; - - var target = new JsValue[arr.Count]; - - for (var i = 0; i < arr.Count; i++) - { - target[i] = Map(arr[i], engine); - } - - return engine.Array.Construct(target); - } - } - - throw new ArgumentException("Invalid json type.", nameof(value)); - } - - private static JsValue FromObject(JToken value, Engine engine) - { - var obj = (JObject)value; - - var target = new ObjectInstance(engine); - - foreach (var property in obj) - { - target.FastAddProperty(property.Key, Map(property.Value, engine), false, true, true); - } - - return target; - } - - public static JToken Map(JsValue value) - { - if (value == null || value.IsNull()) - { - return JValue.CreateNull(); - } - - if (value.IsUndefined()) - { - return JValue.CreateUndefined(); - } - - if (value.IsString()) - { - return new JValue(value.AsString()); - } - - if (value.IsBoolean()) - { - return new JValue(value.AsBoolean()); - } - - if (value.IsNumber()) - { - return new JValue(value.AsNumber()); - } - - if (value.IsDate()) - { - return new JValue(value.AsDate().ToDateTime()); - } - - if (value.IsRegExp()) - { - return JValue.CreateString(value.AsRegExp().Value?.ToString()); - } - - if (value.IsArray()) - { - var arr = value.AsArray(); - - var target = new JArray(); - - for (var i = 0; i < arr.GetLength(); i++) - { - target.Add(Map(arr.Get(i.ToString()))); - } - - return target; - } - - if (value.IsObject()) - { - var obj = value.AsObject(); - - var target = new JObject(); - - foreach (var kvp in obj.GetOwnProperties()) - { - target[kvp.Key] = Map(kvp.Value.Value); - } - - return target; - } - - throw new ArgumentException("Invalid json type.", nameof(value)); - } - } -} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core/Scripting/IScriptEngine.cs b/src/Squidex.Domain.Apps.Core/Scripting/IScriptEngine.cs deleted file mode 100644 index e67acdcb6..000000000 --- a/src/Squidex.Domain.Apps.Core/Scripting/IScriptEngine.cs +++ /dev/null @@ -1,20 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Squidex.Domain.Apps.Core.Contents; - -namespace Squidex.Domain.Apps.Core.Scripting -{ - public interface IScriptEngine - { - void Execute(ScriptContext context, string script); - - NamedContentData ExecuteAndTransform(ScriptContext context, string script); - - NamedContentData Transform(ScriptContext context, string script); - } -} diff --git a/src/Squidex.Domain.Apps.Core/Scripting/JintScriptEngine.cs b/src/Squidex.Domain.Apps.Core/Scripting/JintScriptEngine.cs deleted file mode 100644 index c5ea72ffb..000000000 --- a/src/Squidex.Domain.Apps.Core/Scripting/JintScriptEngine.cs +++ /dev/null @@ -1,177 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using Jint; -using Jint.Native.Object; -using Jint.Parser; -using Jint.Runtime; -using Squidex.Domain.Apps.Core.Contents; -using Squidex.Domain.Apps.Core.Scripting.ContentWrapper; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Core.Scripting -{ - public sealed class JintScriptEngine : IScriptEngine - { - public TimeSpan Timeout { get; set; } = TimeSpan.FromMilliseconds(200); - - public void Execute(ScriptContext context, string script) - { - Guard.NotNull(context, nameof(context)); - - if (!string.IsNullOrWhiteSpace(script)) - { - var engine = CreateScriptEngine(context); - - EnableDisallow(engine); - EnableReject(engine); - - Execute(engine, script); - } - } - - public NamedContentData ExecuteAndTransform(ScriptContext context, string script) - { - Guard.NotNull(context, nameof(context)); - - var result = context.Data; - - if (!string.IsNullOrWhiteSpace(script)) - { - var engine = CreateScriptEngine(context); - - EnableDisallow(engine); - EnableReject(engine); - - engine.SetValue("operation", new Action(() => - { - var dataInstance = engine.GetValue("ctx").AsObject().Get("data"); - - if (dataInstance != null && dataInstance.IsObject() && dataInstance.AsObject() is ContentDataObject data) - { - data.TryUpdate(out result); - } - })); - - engine.SetValue("replace", new Action(() => - { - var dataInstance = engine.GetValue("ctx").AsObject().Get("data"); - - if (dataInstance != null && dataInstance.IsObject() && dataInstance.AsObject() is ContentDataObject data) - { - data.TryUpdate(out result); - } - })); - - Execute(engine, script); - } - - return result; - } - - public NamedContentData Transform(ScriptContext context, string script) - { - Guard.NotNull(context, nameof(context)); - - var result = context.Data; - - if (!string.IsNullOrWhiteSpace(script)) - { - try - { - var engine = CreateScriptEngine(context); - - engine.SetValue("replace", new Action(() => - { - var dataInstance = engine.GetValue("ctx").AsObject().Get("data"); - - if (dataInstance != null && dataInstance.IsObject() && dataInstance.AsObject() is ContentDataObject data) - { - data.TryUpdate(out result); - } - })); - - engine.Execute(script); - } - catch (Exception) - { - result = context.Data; - } - } - - return result; - } - - private static void Execute(Engine engine, string script) - { - try - { - engine.Execute(script); - } - catch (ParserException ex) - { - throw new ValidationException($"Failed to execute script with javascript syntaxs error.", new ValidationError(ex.Message)); - } - catch (JavaScriptException ex) - { - throw new ValidationException($"Failed to execute script with javascript error.", new ValidationError(ex.Message)); - } - } - - private Engine CreateScriptEngine(ScriptContext context) - { - var engine = new Engine(options => options.TimeoutInterval(Timeout).Strict()); - - var contextInstance = new ObjectInstance(engine); - - if (context.Data != null) - { - contextInstance.FastAddProperty("data", new ContentDataObject(engine, context.Data), true, true, true); - } - - if (context.OldData != null) - { - contextInstance.FastAddProperty("oldData", new ContentDataObject(engine, context.OldData), true, true, true); - } - - if (context.User != null) - { - contextInstance.FastAddProperty("user", new JintUser(engine, context.User), false, true, false); - } - - if (!string.IsNullOrWhiteSpace(context.Operation)) - { - contextInstance.FastAddProperty("operation", context.Operation, false, true, false); - } - - engine.SetValue("ctx", contextInstance); - - return engine; - } - - private static void EnableDisallow(Engine engine) - { - engine.SetValue("disallow", new Action(message => - { - var exMessage = !string.IsNullOrWhiteSpace(message) ? message : "Not allowed"; - - throw new DomainForbiddenException(exMessage); - })); - } - - private static void EnableReject(Engine engine) - { - engine.SetValue("reject", new Action(message => - { - var errors = !string.IsNullOrWhiteSpace(message) ? new[] { new ValidationError(message) } : null; - - throw new ValidationException($"Script rejected the operation.", errors); - })); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Scripting/JintUser.cs b/src/Squidex.Domain.Apps.Core/Scripting/JintUser.cs deleted file mode 100644 index 07ab6f0de..000000000 --- a/src/Squidex.Domain.Apps.Core/Scripting/JintUser.cs +++ /dev/null @@ -1,49 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Linq; -using System.Security.Claims; -using Jint; -using Jint.Native; -using Jint.Native.Object; -using Squidex.Infrastructure.Security; - -namespace Squidex.Domain.Apps.Core.Scripting -{ - public sealed class JintUser : ObjectInstance - { - public JintUser(Engine engine, ClaimsPrincipal principal) - : base(engine) - { - var subjectId = principal.OpenIdSubject(); - - var isClient = string.IsNullOrWhiteSpace(subjectId); - - if (!isClient) - { - FastAddProperty("id", subjectId, false, true, false); - FastAddProperty("isClient", false, false, true, false); - } - else - { - FastAddProperty("id", principal.OpenIdClientId(), false, true, false); - FastAddProperty("isClient", true, false, true, false); - } - - FastAddProperty("email", principal.OpenIdEmail(), false, true, false); - - var claimsInstance = new ObjectInstance(engine); - - foreach (var group in principal.Claims.GroupBy(x => x.Type)) - { - claimsInstance.FastAddProperty(group.Key, engine.Array.Construct(group.Select(x => new JsValue(x.Value)).ToArray()), false, true, false); - } - - FastAddProperty("claims", claimsInstance, false, true, false); - } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Scripting/ScriptContext.cs b/src/Squidex.Domain.Apps.Core/Scripting/ScriptContext.cs deleted file mode 100644 index d3d631a40..000000000 --- a/src/Squidex.Domain.Apps.Core/Scripting/ScriptContext.cs +++ /dev/null @@ -1,26 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Security.Claims; -using Squidex.Domain.Apps.Core.Contents; - -namespace Squidex.Domain.Apps.Core.Scripting -{ - public sealed class ScriptContext - { - public ClaimsPrincipal User { get; set; } - - public Guid ContentId { get; set; } - - public NamedContentData Data { get; set; } - - public NamedContentData OldData { get; set; } - - public string Operation { get; set; } - } -} diff --git a/src/Squidex.Domain.Apps.Core/Squidex.Domain.Apps.Core.csproj b/src/Squidex.Domain.Apps.Core/Squidex.Domain.Apps.Core.csproj deleted file mode 100644 index b3c89d3b9..000000000 --- a/src/Squidex.Domain.Apps.Core/Squidex.Domain.Apps.Core.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - netstandard2.0 - - - full - True - - - - - - - - - - - - - - - - - ..\..\Squidex.ruleset - - - - - diff --git a/src/Squidex.Domain.Apps.Core/Webhooks/WebhookSchema.cs b/src/Squidex.Domain.Apps.Core/Webhooks/WebhookSchema.cs deleted file mode 100644 index cd1e05d6c..000000000 --- a/src/Squidex.Domain.Apps.Core/Webhooks/WebhookSchema.cs +++ /dev/null @@ -1,24 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; - -namespace Squidex.Domain.Apps.Core.Webhooks -{ - public sealed class WebhookSchema - { - public Guid SchemaId { get; set; } - - public bool SendCreate { get; set; } - - public bool SendUpdate { get; set; } - - public bool SendDelete { get; set; } - - public bool SendPublish { get; set; } - } -} diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Apps/MongoAppEntity.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Apps/MongoAppEntity.cs deleted file mode 100644 index 8caa41207..000000000 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Apps/MongoAppEntity.cs +++ /dev/null @@ -1,40 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using MongoDB.Bson; -using MongoDB.Bson.Serialization.Attributes; -using Squidex.Domain.Apps.Entities.Apps.State; -using Squidex.Infrastructure.MongoDb; - -namespace Squidex.Domain.Apps.Entities.MongoDb.Apps -{ - public sealed class MongoAppEntity : IVersionedEntity - { - [BsonId] - [BsonElement] - [BsonRepresentation(BsonType.String)] - public Guid Id { get; set; } - - [BsonElement] - [BsonRequired] - [BsonJson] - public AppState State { get; set; } - - [BsonElement] - [BsonRequired] - public long Version { get; set; } - - [BsonElement] - [BsonRequired] - public string Name { get; set; } - - [BsonElement] - [BsonRequired] - public string[] UserIds { get; set; } - } -} diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Apps/MongoAppRepository.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Apps/MongoAppRepository.cs deleted file mode 100644 index 2aa681542..000000000 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Apps/MongoAppRepository.cs +++ /dev/null @@ -1,64 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using MongoDB.Bson; -using MongoDB.Driver; -using Squidex.Domain.Apps.Entities.Apps.Repositories; -using Squidex.Infrastructure.MongoDb; - -namespace Squidex.Domain.Apps.Entities.MongoDb.Apps -{ - public sealed partial class MongoAppRepository : MongoRepositoryBase, IAppRepository - { - public MongoAppRepository(IMongoDatabase database) - : base(database) - { - } - - protected override string CollectionName() - { - return "States_Apps"; - } - - protected override async Task SetupCollectionAsync(IMongoCollection collection) - { - await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.UserIds)); - await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.Name)); - } - - public async Task> QueryAppIdsAsync() - { - var appEntities = - await Collection.Find(new BsonDocument()).Only(x => x.Id) - .ToListAsync(); - - return appEntities.Select(x => Guid.Parse(x["_id"].AsString)).ToList(); - } - - public async Task> QueryUserAppIdsAsync(string userId) - { - var appEntities = - await Collection.Find(x => x.UserIds.Contains(userId)).Only(x => x.Id) - .ToListAsync(); - - return appEntities.Select(x => Guid.Parse(x["_id"].AsString)).ToList(); - } - - public async Task FindAppIdByNameAsync(string name) - { - var appEntity = - await Collection.Find(x => x.Name == name).Only(x => x.Id) - .FirstOrDefaultAsync(); - - return appEntity != null ? Guid.Parse(appEntity["_id"].AsString) : Guid.Empty; - } - } -} diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Apps/MongoAppRepository_SnapshotStore.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Apps/MongoAppRepository_SnapshotStore.cs deleted file mode 100644 index 2fe890dc2..000000000 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Apps/MongoAppRepository_SnapshotStore.cs +++ /dev/null @@ -1,43 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Linq; -using System.Threading.Tasks; -using MongoDB.Driver; -using Squidex.Domain.Apps.Entities.Apps.State; -using Squidex.Infrastructure; -using Squidex.Infrastructure.MongoDb; -using Squidex.Infrastructure.States; - -namespace Squidex.Domain.Apps.Entities.MongoDb.Apps -{ - public sealed partial class MongoAppRepository : ISnapshotStore - { - public async Task<(AppState Value, long Version)> ReadAsync(Guid key) - { - var existing = - await Collection.Find(x => x.Id == key) - .FirstOrDefaultAsync(); - - if (existing != null) - { - return (existing.State, existing.Version); - } - - return (null, EtagVersion.NotFound); - } - - public Task WriteAsync(Guid key, AppState value, long oldVersion, long newVersion) - { - return Collection.UpsertVersionedAsync(key, oldVersion, newVersion, u => u - .Set(x => x.Name, value.Name) - .Set(x => x.State, value) - .Set(x => x.UserIds, value.Contributors.Keys.ToArray())); - } - } -} diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs index 2f2729858..f553712c8 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs @@ -6,7 +6,7 @@ // ========================================================================== using System; -using MongoDB.Bson; +using System.Collections.Generic; using MongoDB.Bson.Serialization.Attributes; using Squidex.Domain.Apps.Core.ValidateContent; using Squidex.Domain.Apps.Entities.Assets; @@ -23,8 +23,8 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets IUpdateableEntityWithLastModifiedBy { [BsonRequired] - [BsonElement] - public Guid AppIdId { get; set; } + [BsonElement("AppIdId")] + public Guid IndexedAppId { get; set; } [BsonRequired] [BsonElement] @@ -70,6 +70,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets [BsonElement] public RefToken LastModifiedBy { get; set; } + [BsonIgnoreIfNull] + [BsonElement] + public HashSet Tags { get; set; } + [BsonElement] public bool IsDeleted { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs index 737c7288c..6c42283c8 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs @@ -11,11 +11,12 @@ using System.Linq; using System.Threading.Tasks; using MongoDB.Driver; using Squidex.Domain.Apps.Entities.Assets; -using Squidex.Domain.Apps.Entities.Assets.Edm; using Squidex.Domain.Apps.Entities.Assets.Repositories; using Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors; using Squidex.Infrastructure; +using Squidex.Infrastructure.Log; using Squidex.Infrastructure.MongoDb; +using Squidex.Infrastructure.Queries; namespace Squidex.Domain.Apps.Entities.MongoDb.Assets { @@ -34,73 +35,81 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets protected override Task SetupCollectionAsync(IMongoCollection collection) { return collection.Indexes.CreateOneAsync( - Index - .Ascending(x => x.AppId) - .Ascending(x => x.IsDeleted) - .Ascending(x => x.FileName) - .Descending(x => x.LastModified)); + new CreateIndexModel( + Index + .Ascending(x => x.AppId) + .Ascending(x => x.IsDeleted) + .Ascending(x => x.FileName) + .Ascending(x => x.Tags) + .Descending(x => x.LastModified))); } - public async Task> QueryAsync(Guid appId, string query = null) + public async Task> QueryAsync(Guid appId, Query query) { - try + using (Profiler.TraceMethod("QueryAsyncByQuery")) { - var odataQuery = EdmAssetModel.Edm.ParseQuery(query); + try + { + query = query.AdjustToModel(); - var filter = FindExtensions.BuildQuery(odataQuery, appId); + var filter = query.BuildFilter(appId); - var contentCount = Collection.Find(filter).CountAsync(); - var contentItems = - Collection.Find(filter) - .AssetTake(odataQuery) - .AssetSkip(odataQuery) - .AssetSort(odataQuery) - .ToListAsync(); + var contentCount = Collection.Find(filter).CountDocumentsAsync(); + var contentItems = + Collection.Find(filter) + .AssetTake(query) + .AssetSkip(query) + .AssetSort(query) + .ToListAsync(); - await Task.WhenAll(contentItems, contentCount); + await Task.WhenAll(contentItems, contentCount); - return ResultList.Create(contentItems.Result, contentCount.Result); - } - catch (NotSupportedException) - { - throw new ValidationException("This odata operation is not supported."); - } - catch (NotImplementedException) - { - throw new ValidationException("This odata operation is not supported."); - } - catch (MongoQueryException ex) - { - if (ex.Message.Contains("17406")) - { - throw new DomainException("Result set is too large to be retrieved. Use $top parameter to reduce the number of items."); + return ResultList.Create(contentCount.Result, contentItems.Result); } - else + catch (MongoQueryException ex) { - throw; + if (ex.Message.Contains("17406")) + { + throw new DomainException("Result set is too large to be retrieved. Use $top parameter to reduce the number of items."); + } + else + { + throw; + } } } } public async Task> QueryAsync(Guid appId, HashSet ids) { - var find = Collection.Find(Filter.In(x => x.Id, ids)).SortByDescending(x => x.LastModified); + using (Profiler.TraceMethod("QueryAsyncByIds")) + { + var find = Collection.Find(x => ids.Contains(x.Id)).SortByDescending(x => x.LastModified); - var assetItems = find.ToListAsync(); - var assetCount = find.CountAsync(); + var assetItems = find.ToListAsync(); + var assetCount = find.CountDocumentsAsync(); - await Task.WhenAll(assetItems, assetCount); + await Task.WhenAll(assetItems, assetCount); - return ResultList.Create(assetItems.Result.OfType().ToList(), assetCount.Result); + return ResultList.Create(assetCount.Result, assetItems.Result.OfType()); + } } public async Task FindAssetAsync(Guid id) { - var assetEntity = - await Collection.Find(x => x.Id == id) - .FirstOrDefaultAsync(); + using (Profiler.TraceMethod()) + { + var assetEntity = + await Collection.Find(x => x.Id == id) + .FirstOrDefaultAsync(); - return assetEntity; + return assetEntity; + } + } + + public Task RemoveAsync(Guid appId) + { + return Collection.DeleteManyAsync(x => x.IndexedAppId == appId); } } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs index a4056d0a7..189f08052 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using MongoDB.Driver; using Squidex.Domain.Apps.Entities.Assets.State; using Squidex.Infrastructure; +using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.States; @@ -19,26 +20,42 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets { public async Task<(AssetState Value, long Version)> ReadAsync(Guid key) { - var existing = - await Collection.Find(x => x.Id == key) - .FirstOrDefaultAsync(); - - if (existing != null) + using (Profiler.TraceMethod()) { - return (SimpleMapper.Map(existing, new AssetState()), existing.Version); - } + var existing = + await Collection.Find(x => x.Id == key) + .FirstOrDefaultAsync(); + + if (existing != null) + { + return (SimpleMapper.Map(existing, new AssetState()), existing.Version); + } - return (null, EtagVersion.NotFound); + return (null, EtagVersion.NotFound); + } } public async Task WriteAsync(Guid key, AssetState value, long oldVersion, long newVersion) { - var entity = SimpleMapper.Map(value, new MongoAssetEntity()); + using (Profiler.TraceMethod()) + { + var entity = SimpleMapper.Map(value, new MongoAssetEntity()); + + entity.Version = newVersion; + entity.IndexedAppId = value.AppId.Id; - entity.Version = newVersion; - entity.AppIdId = value.AppId.Id; + await Collection.ReplaceOneAsync(x => x.Id == key && x.Version == oldVersion, entity, Upsert); + } + } + + Task ISnapshotStore.ReadAllAsync(Func callback) + { + throw new NotSupportedException(); + } - await Collection.ReplaceOneAsync(x => x.Id == key && x.Version == oldVersion, entity, Upsert); + Task ISnapshotStore.RemoveAsync(Guid key) + { + throw new NotSupportedException(); } } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetStatsRepository.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetStatsRepository.cs index 983c8971d..dd53f4313 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetStatsRepository.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetStatsRepository.cs @@ -32,8 +32,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets protected override async Task SetupCollectionAsync(IMongoCollection collection) { - await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.AssetId).Ascending(x => x.Date)); - await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.AssetId).Descending(x => x.Date)); + await collection.Indexes.CreateOneAsync( + new CreateIndexModel(Index.Ascending(x => x.AssetId).Ascending(x => x.Date))); + await collection.Indexes.CreateOneAsync( + new CreateIndexModel(Index.Ascending(x => x.AssetId).Descending(x => x.Date))); } public async Task> QueryAsync(Guid appId, DateTime fromDate, DateTime toDate) diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs index af3a75764..3d6a94a91 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs @@ -7,55 +7,60 @@ using System; using System.Collections.Generic; -using Microsoft.OData.UriParser; +using System.Linq; using MongoDB.Bson; using MongoDB.Driver; using Squidex.Infrastructure; using Squidex.Infrastructure.MongoDb.OData; +using Squidex.Infrastructure.Queries; namespace Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors { public static class FindExtensions { private static readonly FilterDefinitionBuilder Filter = Builders.Filter; - private static readonly PropertyCalculator PropertyCalculator = propertyNames => + + public static Query AdjustToModel(this Query query) { - if (propertyNames.Length > 0) + if (query.Filter != null) { - propertyNames[0] = propertyNames[0].ToPascalCase(); + query.Filter = PascalCasePathConverter.Transform(query.Filter); } - var propertyName = string.Join(".", propertyNames); + query.Sort = query.Sort + .Select(x => + new SortNode( + x.Path.Select(p => p.ToPascalCase()).ToList(), + x.SortOrder)) + .ToList(); - return propertyName; - }; + return query; + } - public static IFindFluent AssetSort(this IFindFluent cursor, ODataUriParser query) + public static IFindFluent AssetSort(this IFindFluent cursor, Query query) { - var sort = query.BuildSort(PropertyCalculator); - - return sort != null ? cursor.Sort(sort) : cursor.SortByDescending(x => x.LastModified); + return cursor.Sort(query.BuildSort()); } - public static IFindFluent AssetTake(this IFindFluent cursor, ODataUriParser query) + public static IFindFluent AssetTake(this IFindFluent cursor, Query query) { - return cursor.Take(query, 200, 20); + return cursor.Take(query); } - public static IFindFluent AssetSkip(this IFindFluent cursor, ODataUriParser query) + public static IFindFluent AssetSkip(this IFindFluent cursor, Query query) { return cursor.Skip(query); } - public static FilterDefinition BuildQuery(ODataUriParser query, Guid appId) + public static FilterDefinition BuildFilter(this Query query, Guid appId) { var filters = new List> { - Filter.Eq(x => x.AppIdId, appId), + Filter.Eq(x => x.IndexedAppId, appId), Filter.Eq(x => x.IsDeleted, false) }; - var filter = query.BuildFilter(PropertyCalculator, false); + var filter = query.BuildFilter(false); if (filter.Filter != null) { diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Extensions.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Extensions.cs index 0d30a2e6b..c1f982348 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Extensions.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Extensions.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Text; using Newtonsoft.Json.Linq; @@ -26,9 +27,24 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents return data.GetReferencedIds(schema).ToList(); } - public static NamedContentData ToData(this IdContentData idData, Schema schema, List deletedIds) + public static NamedContentData FromMongoModel(this IdContentData result, Schema schema, List deletedIds) { - return idData.ToCleanedReferences(schema, new HashSet(deletedIds)).ToNameModel(schema, true); + return result.ConvertId2Name(schema, + FieldConverters.ForValues( + ValueConverters.DecodeJson(), + ValueReferencesConverter.CleanReferences(deletedIds)), + FieldConverters.ForNestedId2Name( + ValueConverters.DecodeJson(), + ValueReferencesConverter.CleanReferences(deletedIds))); + } + + public static IdContentData ToMongoModel(this NamedContentData result, Schema schema) + { + return result.ConvertName2Id(schema, + FieldConverters.ForValues( + ValueConverters.EncodeJson()), + FieldConverters.ForNestedName2Id( + ValueConverters.EncodeJson())); } public static string ToFullText(this ContentData data) @@ -39,11 +55,15 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents { if (text.Type == JTokenType.String) { - var value = text.ToString(); + var value = text.ToString(CultureInfo.InvariantCulture); if (value.Length < 1000) { - stringBuilder.Append(" "); + if (stringBuilder.Length > 0) + { + stringBuilder.Append(" "); + } + stringBuilder.Append(text); } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs new file mode 100644 index 000000000..f9a7b2d25 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs @@ -0,0 +1,118 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using MongoDB.Driver; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Contents; +using Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; +using Squidex.Infrastructure.MongoDb; +using Squidex.Infrastructure.Queries; + +namespace Squidex.Domain.Apps.Entities.MongoDb.Contents +{ + internal class MongoContentCollection : MongoRepositoryBase + { + private readonly string collectionName; + + public MongoContentCollection(IMongoDatabase database, string collectionName) + : base(database) + { + this.collectionName = collectionName; + } + + protected override async Task SetupCollectionAsync(IMongoCollection collection) + { + await collection.Indexes.CreateOneAsync( + new CreateIndexModel(Index.Ascending(x => x.ReferencedIds))); + } + + protected override string CollectionName() + { + return collectionName; + } + + public async Task> QueryAsync(IAppEntity app, ISchemaEntity schema, Query query, Status[] status = null, bool useDraft = false) + { + try + { + query = query.AdjustToModel(schema.SchemaDef, useDraft); + + var filter = FindExtensions.BuildQuery(query, schema.Id, status); + + var contentCount = Collection.Find(filter).CountDocumentsAsync(); + var contentItems = + Collection.Find(filter) + .ContentTake(query) + .ContentSkip(query) + .ContentSort(query) + .Not(x => x.DataText) + .ToListAsync(); + + await Task.WhenAll(contentItems, contentCount); + + foreach (var entity in contentItems.Result) + { + entity.ParseData(schema.SchemaDef); + } + + return ResultList.Create(contentCount.Result, contentItems.Result); + } + catch (MongoQueryException ex) + { + if (ex.Message.Contains("17406")) + { + throw new DomainException("Result set is too large to be retrieved. Use $top parameter to reduce the number of items."); + } + else + { + throw; + } + } + } + + public async Task> QueryAsync(IAppEntity app, ISchemaEntity schema, HashSet ids, Status[] status = null) + { + var find = + status != null && status.Length > 0 ? + Collection.Find(x => x.IndexedSchemaId == schema.Id && ids.Contains(x.Id) && x.IsDeleted != true && status.Contains(x.Status)) : + Collection.Find(x => x.IndexedSchemaId == schema.Id && ids.Contains(x.Id)); + + var contentItems = find.Not(x => x.DataText).ToListAsync(); + var contentCount = find.CountDocumentsAsync(); + + await Task.WhenAll(contentItems, contentCount); + + foreach (var entity in contentItems.Result) + { + entity.ParseData(schema.SchemaDef); + } + + return ResultList.Create(contentCount.Result, contentItems.Result); + } + + public Task CleanupAsync(Guid id) + { + return Collection.UpdateManyAsync( + Filter.And( + Filter.AnyEq(x => x.ReferencedIds, id), + Filter.AnyNe(x => x.ReferencedIdsDeleted, id)), + Update.AddToSet(x => x.ReferencedIdsDeleted, id)); + } + + public Task RemoveAsync(Guid id) + { + return Collection.DeleteOneAsync(x => x.Id == id); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentDraftCollection.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentDraftCollection.cs new file mode 100644 index 000000000..15cdb7f32 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentDraftCollection.cs @@ -0,0 +1,139 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using MongoDB.Driver; +using NodaTime; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Contents; +using Squidex.Domain.Apps.Entities.Contents.State; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; +using Squidex.Infrastructure.MongoDb; +using Squidex.Infrastructure.Reflection; +using Squidex.Infrastructure.States; + +namespace Squidex.Domain.Apps.Entities.MongoDb.Contents +{ + internal sealed class MongoContentDraftCollection : MongoContentCollection + { + public MongoContentDraftCollection(IMongoDatabase database) + : base(database, "State_Content_Draft") + { + } + + protected override async Task SetupCollectionAsync(IMongoCollection collection) + { + await collection.Indexes.CreateOneAsync( + new CreateIndexModel( + Index + .Ascending(x => x.IndexedSchemaId) + .Ascending(x => x.Id) + .Ascending(x => x.IsDeleted))); + + await collection.Indexes.CreateOneAsync( + new CreateIndexModel( + Index + .Text(x => x.DataText) + .Ascending(x => x.IndexedSchemaId) + .Ascending(x => x.IsDeleted) + .Ascending(x => x.Status))); + + await base.SetupCollectionAsync(collection); + } + + public async Task> QueryNotFoundAsync(Guid appId, Guid schemaId, IList ids) + { + var contentEntities = + await Collection.Find(x => x.IndexedSchemaId == schemaId && ids.Contains(x.Id) && x.IsDeleted != true).Only(x => x.Id) + .ToListAsync(); + + return ids.Except(contentEntities.Select(x => Guid.Parse(x["_id"].AsString))).ToList(); + } + + public async Task> QueryIdsAsync(Guid appId) + { + var contentEntities = + await Collection.Find(x => x.IndexedAppId == appId).Only(x => x.Id) + .ToListAsync(); + + return contentEntities.Select(x => Guid.Parse(x["_id"].AsString)).ToList(); + } + + public Task QueryScheduledWithoutDataAsync(Instant now, Func callback) + { + return Collection.Find(x => x.ScheduledAt < now && x.IsDeleted != true) + .Not(x => x.DataByIds) + .Not(x => x.DataDraftByIds) + .Not(x => x.DataText) + .ForEachAsync(c => + { + callback(c); + }); + } + + public async Task FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id) + { + var contentEntity = + await Collection.Find(x => x.IndexedSchemaId == schema.Id && x.Id == id && x.IsDeleted != true).Not(x => x.DataText) + .FirstOrDefaultAsync(); + + contentEntity?.ParseData(schema.SchemaDef); + + return contentEntity; + } + + public async Task<(ContentState Value, long Version)> ReadAsync(Guid key, Func> getSchema) + { + var contentEntity = + await Collection.Find(x => x.Id == key).Not(x => x.DataText) + .FirstOrDefaultAsync(); + + if (contentEntity != null) + { + var schema = await getSchema(contentEntity.IndexedAppId, contentEntity.IndexedSchemaId); + + contentEntity.ParseData(schema.SchemaDef); + + return (SimpleMapper.Map(contentEntity, new ContentState()), contentEntity.Version); + } + + return (null, EtagVersion.NotFound); + } + + public async Task UpsertAsync(MongoContentEntity content, long oldVersion) + { + try + { + content.DataText = content.DataDraftByIds.ToFullText(); + + await Collection.ReplaceOneAsync(x => x.Id == content.Id && x.Version == oldVersion, content, Upsert); + } + catch (MongoWriteException ex) + { + if (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) + { + var existingVersion = + await Collection.Find(x => x.Id == content.Id).Only(x => x.Id, x => x.Version) + .FirstOrDefaultAsync(); + + if (existingVersion != null) + { + throw new InconsistentStateException(existingVersion["vs"].AsInt64, oldVersion, ex); + } + } + else + { + throw; + } + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs index f35cb2286..b758c7919 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs @@ -21,26 +21,22 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents public sealed class MongoContentEntity : IContentEntity { private NamedContentData data; + private NamedContentData dataDraft; [BsonId] - [BsonRequired] [BsonElement] - public string DocumentId { get; set; } - - [BsonRequired] - [BsonElement("id")] [BsonRepresentation(BsonType.String)] public Guid Id { get; set; } [BsonRequired] - [BsonElement("ai")] + [BsonElement("_ai")] [BsonRepresentation(BsonType.String)] - public Guid AppIdId { get; set; } + public Guid IndexedAppId { get; set; } [BsonRequired] - [BsonElement("si")] + [BsonElement("_si")] [BsonRepresentation(BsonType.String)] - public Guid SchemaIdId { get; set; } + public Guid IndexedSchemaId { get; set; } [BsonRequired] [BsonElement("rf")] @@ -53,35 +49,41 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents public List ReferencedIdsDeleted { get; set; } = new List(); [BsonRequired] - [BsonElement("st")] + [BsonElement("ss")] [BsonRepresentation(BsonType.String)] public Status Status { get; set; } - [BsonRequired] + [BsonIgnoreIfNull] [BsonElement("do")] [BsonJson] public IdContentData DataByIds { get; set; } + [BsonIgnoreIfNull] + [BsonElement("dd")] + [BsonJson] + public IdContentData DataDraftByIds { get; set; } + + [BsonIgnoreIfNull] + [BsonElement("sj")] + [BsonJson] + public ScheduleJob ScheduleJob { get; set; } + + [BsonIgnoreIfDefault] + [BsonElement("dt")] + public string DataText { get; set; } + [BsonRequired] - [BsonElement("ai2")] + [BsonElement("ai")] public NamedId AppId { get; set; } [BsonRequired] - [BsonElement("si2")] + [BsonElement("si")] public NamedId SchemaId { get; set; } [BsonIgnoreIfNull] - [BsonElement("sdt")] - public Status? ScheduledTo { get; set; } - - [BsonIgnoreIfNull] - [BsonElement("sda")] + [BsonElement("sa")] public Instant? ScheduledAt { get; set; } - [BsonIgnoreIfNull] - [BsonElement("sdb")] - public RefToken ScheduledBy { get; set; } - [BsonRequired] [BsonElement("ct")] public Instant Created { get; set; } @@ -90,18 +92,18 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents [BsonElement("mt")] public Instant LastModified { get; set; } - [BsonRequired] - [BsonElement("dt")] - public string DataText { get; set; } - [BsonRequired] [BsonElement("vs")] public long Version { get; set; } - [BsonRequired] + [BsonIgnoreIfDefault] [BsonElement("dl")] public bool IsDeleted { get; set; } + [BsonIgnoreIfDefault] + [BsonElement("pd")] + public bool IsPending { get; set; } + [BsonRequired] [BsonElement("cb")] public RefToken CreatedBy { get; set; } @@ -116,9 +118,20 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents get { return data; } } + [BsonIgnore] + public NamedContentData DataDraft + { + get { return dataDraft; } + } + public void ParseData(Schema schema) { - data = DataByIds.ToData(schema, ReferencedIdsDeleted); + data = DataByIds.FromMongoModel(schema, ReferencedIdsDeleted); + + if (DataDraftByIds != null) + { + dataDraft = DataDraftByIds.FromMongoModel(schema, ReferencedIdsDeleted); + } } } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentPublishedCollection.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentPublishedCollection.cs new file mode 100644 index 000000000..ec1d2da7c --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentPublishedCollection.cs @@ -0,0 +1,60 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using MongoDB.Driver; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Contents; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure.MongoDb; + +namespace Squidex.Domain.Apps.Entities.MongoDb.Contents +{ + internal sealed class MongoContentPublishedCollection : MongoContentCollection + { + public MongoContentPublishedCollection(IMongoDatabase database) + : base(database, "State_Content_Published") + { + } + + protected override async Task SetupCollectionAsync(IMongoCollection collection) + { + await collection.Indexes.CreateOneAsync( + new CreateIndexModel(Index.Text(x => x.DataText).Ascending(x => x.IndexedSchemaId))); + + await collection.Indexes.CreateOneAsync( + new CreateIndexModel( + Index + .Ascending(x => x.IndexedSchemaId) + .Ascending(x => x.Id))); + + await base.SetupCollectionAsync(collection); + } + + public async Task FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id) + { + var contentEntity = + await Collection.Find(x => x.IndexedSchemaId == schema.Id && x.Id == id).Not(x => x.DataText) + .FirstOrDefaultAsync(); + + contentEntity?.ParseData(schema.SchemaDef); + + return contentEntity; + } + + public Task UpsertAsync(MongoContentEntity content) + { + content.DataText = content.DataByIds.ToFullText(); + content.DataDraftByIds = null; + content.ScheduleJob = null; + content.ScheduledAt = null; + + return Collection.ReplaceOneAsync(x => x.Id == content.Id, content, new UpdateOptions { IsUpsert = true }); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs index 1d4cc508f..796026d71 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs @@ -7,185 +7,136 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; -using Microsoft.OData.UriParser; using MongoDB.Driver; using NodaTime; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Contents.Repositories; -using Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; -using Squidex.Infrastructure.MongoDb; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Queries; namespace Squidex.Domain.Apps.Entities.MongoDb.Contents { - public partial class MongoContentRepository : MongoRepositoryBase, IContentRepository + public partial class MongoContentRepository : IContentRepository, IInitializable { + private readonly IMongoDatabase database; private readonly IAppProvider appProvider; - private readonly IMongoCollection archiveCollection; - - protected IMongoCollection ArchiveCollection - { - get { return archiveCollection; } - } + private readonly MongoContentDraftCollection contentsDraft; + private readonly MongoContentPublishedCollection contentsPublished; public MongoContentRepository(IMongoDatabase database, IAppProvider appProvider) - : base(database) { Guard.NotNull(appProvider, nameof(appProvider)); this.appProvider = appProvider; - archiveCollection = database.GetCollection("States_Contents_Archive"); - } + contentsDraft = new MongoContentDraftCollection(database); + contentsPublished = new MongoContentPublishedCollection(database); - protected override string CollectionName() - { - return "States_Contents"; + this.database = database; } - protected override async Task SetupCollectionAsync(IMongoCollection collection) + public void Initialize() { - await collection.Indexes.TryDropOneAsync("si_1_st_1_dl_1_dt_text"); - - await archiveCollection.Indexes.CreateOneAsync( - Index - .Ascending(x => x.ScheduledTo)); - - await archiveCollection.Indexes.CreateOneAsync( - Index - .Ascending(x => x.Id) - .Ascending(x => x.Version)); - - await collection.Indexes.CreateOneAsync( - Index - .Text(x => x.DataText) - .Ascending(x => x.SchemaIdId) - .Ascending(x => x.Status) - .Ascending(x => x.IsDeleted)); - - await collection.Indexes.CreateOneAsync( - Index - .Ascending(x => x.SchemaIdId) - .Ascending(x => x.Id) - .Ascending(x => x.IsDeleted) - .Ascending(x => x.Status)); - - await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.ReferencedIds)); + contentsDraft.Initialize(); + contentsPublished.Initialize(); } - public async Task> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[] status, ODataUriParser odataQuery) + public async Task> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[] status, Query query) { - try + using (Profiler.TraceMethod("QueryAsyncByQuery")) { - var propertyCalculator = FindExtensions.CreatePropertyCalculator(schema.SchemaDef); - - var filter = FindExtensions.BuildQuery(odataQuery, schema.Id, status, propertyCalculator); - - var contentCount = Collection.Find(filter).CountAsync(); - var contentItems = - Collection.Find(filter) - .ContentTake(odataQuery) - .ContentSkip(odataQuery) - .ContentSort(odataQuery, propertyCalculator) - .ToListAsync(); - - await Task.WhenAll(contentItems, contentCount); - - foreach (var entity in contentItems.Result) + if (RequiresPublished(status)) { - entity.ParseData(schema.SchemaDef); + return await contentsPublished.QueryAsync(app, schema, query); + } + else + { + return await contentsDraft.QueryAsync(app, schema, query, status, true); } - - return ResultList.Create(contentItems.Result, contentCount.Result); - } - catch (NotSupportedException) - { - throw new ValidationException("This odata operation is not supported."); - } - catch (NotImplementedException) - { - throw new ValidationException("This odata operation is not supported."); } - catch (MongoQueryException ex) + } + + public async Task> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[] status, HashSet ids) + { + using (Profiler.TraceMethod("QueryAsyncByIds")) { - if (ex.Message.Contains("17406")) + if (RequiresPublished(status)) { - throw new DomainException("Result set is too large to be retrieved. Use $top parameter to reduce the number of items."); + return await contentsPublished.QueryAsync(app, schema, ids); } else { - throw; + return await contentsDraft.QueryAsync(app, schema, ids, status); } } } - public async Task> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[] status, HashSet ids) + public async Task FindContentAsync(IAppEntity app, ISchemaEntity schema, Status[] status, Guid id) { - var find = Collection.Find(x => x.SchemaIdId == schema.Id && ids.Contains(x.Id) && x.IsDeleted == false && status.Contains(x.Status)); - - var contentItems = find.ToListAsync(); - var contentCount = find.CountAsync(); - - await Task.WhenAll(contentItems, contentCount); - - foreach (var entity in contentItems.Result) + using (Profiler.TraceMethod()) { - entity.ParseData(schema.SchemaDef); + if (RequiresPublished(status)) + { + return await contentsPublished.FindContentAsync(app, schema, id); + } + else + { + return await contentsDraft.FindContentAsync(app, schema, id); + } } - - return ResultList.Create(contentItems.Result, contentCount.Result); } public async Task> QueryNotFoundAsync(Guid appId, Guid schemaId, IList ids) { - var contentEntities = - await Collection.Find(x => x.SchemaIdId == schemaId && ids.Contains(x.Id) && x.IsDeleted == false).Only(x => x.Id) - .ToListAsync(); - - return ids.Except(contentEntities.Select(x => Guid.Parse(x["id"].AsString))).ToList(); + using (Profiler.TraceMethod()) + { + return await contentsDraft.QueryNotFoundAsync(appId, schemaId, ids); + } } - public async Task FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id, long version) + public async Task> QueryIdsAsync(Guid appId) { - var contentEntity = - await ArchiveCollection.Find(x => x.Id == id && x.Version >= version).SortBy(x => x.Version) - .FirstOrDefaultAsync(); - - contentEntity?.ParseData(schema.SchemaDef); - - return contentEntity; + using (Profiler.TraceMethod()) + { + return await contentsDraft.QueryIdsAsync(appId); + } } - public async Task FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id) + public async Task QueryScheduledWithoutDataAsync(Instant now, Func callback) { - var contentEntity = - await Collection.Find(x => x.SchemaIdId == schema.Id && x.Id == id && x.IsDeleted == false) - .FirstOrDefaultAsync(); - - contentEntity?.ParseData(schema.SchemaDef); + using (Profiler.TraceMethod()) + { + await contentsDraft.QueryScheduledWithoutDataAsync(now, callback); + } + } - return contentEntity; + public Task RemoveAsync(Guid appId) + { + return Task.WhenAll( + contentsDraft.RemoveAsync(appId), + contentsPublished.RemoveAsync(appId)); } - public Task QueryScheduledWithoutDataAsync(Instant now, Func callback) + public Task ClearAsync() { - return Collection.Find(x => x.ScheduledAt < now && x.IsDeleted == false) - .ForEachAsync(c => - { - callback(c); - }); + return Task.WhenAll( + contentsDraft.ClearAsync(), + contentsPublished.ClearAsync()); } - public override async Task ClearAsync() + public Task DeleteArchiveAsync() { - await Database.DropCollectionAsync("States_Contents_Archive"); + return database.DropCollectionAsync("States_Contents_Archive"); + } - await base.ClearAsync(); + private static bool RequiresPublished(Status[] status) + { + return status?.Length == 1 && status[0] == Status.Published; } } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_EventHandling.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_EventHandling.cs index 8a923d361..427629d1d 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_EventHandling.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_EventHandling.cs @@ -33,20 +33,16 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents protected Task On(AssetDeleted @event) { - return Collection.UpdateManyAsync( - Filter.And( - Filter.AnyEq(x => x.ReferencedIds, @event.AssetId), - Filter.AnyNe(x => x.ReferencedIdsDeleted, @event.AssetId)), - Update.AddToSet(x => x.ReferencedIdsDeleted, @event.AssetId)); + return Task.WhenAll( + contentsDraft.CleanupAsync(@event.AssetId), + contentsPublished.CleanupAsync(@event.AssetId)); } protected Task On(ContentDeleted @event) { - return Collection.UpdateManyAsync( - Filter.And( - Filter.AnyEq(x => x.ReferencedIds, @event.ContentId), - Filter.AnyNe(x => x.ReferencedIdsDeleted, @event.ContentId)), - Update.AddToSet(x => x.ReferencedIdsDeleted, @event.ContentId)); + return Task.WhenAll( + contentsDraft.CleanupAsync(@event.ContentId), + contentsPublished.CleanupAsync(@event.ContentId)); } Task IEventConsumer.ClearAsync() diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs index 54e236e4a..cd6700635 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs @@ -7,12 +7,11 @@ using System; using System.Threading.Tasks; -using MongoDB.Driver; -using Squidex.Domain.Apps.Core.ConvertContent; +using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Contents.State; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; -using Squidex.Infrastructure.MongoDb; +using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.States; @@ -22,74 +21,54 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents { public async Task<(ContentState Value, long Version)> ReadAsync(Guid key) { - var contentEntity = - await Collection.Find(x => x.Id == key).SortByDescending(x => x.Version) - .FirstOrDefaultAsync(); - - if (contentEntity != null) + using (Profiler.TraceMethod()) { - var schema = await GetSchemaAsync(contentEntity.AppIdId, contentEntity.SchemaIdId); - - contentEntity?.ParseData(schema.SchemaDef); - - return (SimpleMapper.Map(contentEntity, new ContentState()), contentEntity.Version); + return await contentsDraft.ReadAsync(key, GetSchemaAsync); } - - return (null, EtagVersion.NotFound); } public async Task WriteAsync(Guid key, ContentState value, long oldVersion, long newVersion) { - if (value.SchemaId.Id == Guid.Empty) + using (Profiler.TraceMethod()) { - return; - } - - var schema = await GetSchemaAsync(value.AppId.Id, value.SchemaId.Id); - - var idData = value.Data?.ToIdModel(schema.SchemaDef, true); - - var id = key.ToString(); + if (value.SchemaId.Id == Guid.Empty) + { + return; + } - var document = SimpleMapper.Map(value, new MongoContentEntity - { - AppIdId = value.AppId.Id, - SchemaIdId = value.SchemaId.Id, - IsDeleted = value.IsDeleted, - DocumentId = key.ToString(), - DataText = idData?.ToFullText(), - DataByIds = idData, - ReferencedIds = idData?.ToReferencedIds(schema.SchemaDef), - }); + var schema = await GetSchemaAsync(value.AppId.Id, value.SchemaId.Id); - document.Version = newVersion; + var idData = value.Data.ToMongoModel(schema.SchemaDef); + var idDraftData = idData; - try - { - await Collection.ReplaceOneAsync(x => x.DocumentId == id && x.Version == oldVersion, document, Upsert); - } - catch (MongoWriteException ex) - { - if (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) + if (!ReferenceEquals(value.Data, value.DataDraft)) { - var existingVersion = - await Collection.Find(x => x.DocumentId == id).Only(x => x.DocumentId, x => x.Version) - .FirstOrDefaultAsync(); + idDraftData = value.DataDraft?.ToMongoModel(schema.SchemaDef); + } - if (existingVersion != null) - { - throw new InconsistentStateException(existingVersion["vs"].AsInt64, oldVersion, ex); - } + var content = SimpleMapper.Map(value, new MongoContentEntity + { + DataByIds = idData, + DataDraftByIds = idDraftData, + IsDeleted = value.IsDeleted, + IndexedAppId = value.AppId.Id, + IndexedSchemaId = value.SchemaId.Id, + ReferencedIds = idData.ToReferencedIds(schema.SchemaDef), + ScheduledAt = value.ScheduleJob?.DueTime, + Version = newVersion + }); + + await contentsDraft.UpsertAsync(content, oldVersion); + + if (value.Status == Status.Published && !value.IsDeleted) + { + await contentsPublished.UpsertAsync(content); } else { - throw; + await contentsPublished.RemoveAsync(content.Id); } } - - document.DocumentId = $"{key}_{newVersion}"; - - await ArchiveCollection.ReplaceOneAsync(x => x.DocumentId == document.DocumentId, document, Upsert); } private async Task GetSchemaAsync(Guid appId, Guid schemaId) @@ -103,5 +82,15 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents return schema; } + + Task ISnapshotStore.RemoveAsync(Guid key) + { + throw new NotSupportedException(); + } + + Task ISnapshotStore.ReadAllAsync(Func callback) + { + throw new NotSupportedException(); + } } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FindExtensions.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FindExtensions.cs index cdfaff9b8..caca0314e 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FindExtensions.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FindExtensions.cs @@ -9,13 +9,14 @@ using System; using System.Collections.Generic; using System.Linq; using System.Reflection; -using Microsoft.OData.UriParser; using MongoDB.Bson.Serialization.Attributes; using MongoDB.Driver; +using NodaTime; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.GenerateEdmSchema; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Infrastructure.MongoDb.OData; +using Squidex.Infrastructure.Queries; namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors { @@ -27,65 +28,109 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors typeof(MongoContentEntity).GetProperties() .ToDictionary(x => x.Name, x => x.GetCustomAttribute()?.ElementName ?? x.Name, StringComparer.OrdinalIgnoreCase); - static FindExtensions() + private sealed class AdaptionVisitor : TransformVisitor { - PropertyMap["Data"] = "do"; + private readonly Func, IReadOnlyList> pathConverter; + + public AdaptionVisitor(Func, IReadOnlyList> pathConverter) + { + this.pathConverter = pathConverter; + } + + public override FilterNode Visit(FilterComparison nodeIn) + { + var value = nodeIn.Rhs.Value; + + if (value is Instant instant && + !string.Equals(nodeIn.Lhs[0], "mt", StringComparison.OrdinalIgnoreCase) && + !string.Equals(nodeIn.Lhs[0], "ct", StringComparison.OrdinalIgnoreCase)) + { + return new FilterComparison(pathConverter(nodeIn.Lhs), nodeIn.Operator, new FilterValue(value.ToString())); + } + + return new FilterComparison(pathConverter(nodeIn.Lhs), nodeIn.Operator, nodeIn.Rhs); + } } - public static PropertyCalculator CreatePropertyCalculator(Schema schema) + public static Query AdjustToModel(this Query query, Schema schema, bool useDraft) { - return propertyNames => + var pathConverter = new Func, IReadOnlyList>(propertyNames => { - if (propertyNames.Length > 1) + var result = new List(propertyNames); + + if (result.Count > 1) { - var edmName = propertyNames[1].UnescapeEdmField(); + var edmName = result[1].UnescapeEdmField(); if (!schema.FieldsByName.TryGetValue(edmName, out var field)) { throw new NotSupportedException(); } - propertyNames[1] = field.Id.ToString(); + result[1] = field.Id.ToString(); } - if (propertyNames.Length > 0) + if (result.Count > 0) { - propertyNames[0] = PropertyMap[propertyNames[0]]; + if (result[0].Equals("Data", StringComparison.CurrentCultureIgnoreCase)) + { + if (useDraft) + { + result[0] = "dd"; + } + else + { + result[0] = "do"; + } + } + else + { + result[0] = PropertyMap[propertyNames[0]]; + } } - var propertyName = string.Join(".", propertyNames); + return result; + }); - return propertyName; - }; + if (query.Filter != null) + { + query.Filter = query.Filter.Accept(new AdaptionVisitor(pathConverter)); + } + + query.Sort = query.Sort.Select(x => new SortNode(pathConverter(x.Path), x.SortOrder)).ToList(); + + return query; } - public static IFindFluent ContentSort(this IFindFluent cursor, ODataUriParser query, PropertyCalculator propertyCalculator) + public static IFindFluent ContentSort(this IFindFluent cursor, Query query) { - var sort = query.BuildSort(propertyCalculator); - - return sort != null ? cursor.Sort(sort) : cursor.SortByDescending(x => x.LastModified); + return cursor.Sort(query.BuildSort()); } - public static IFindFluent ContentTake(this IFindFluent cursor, ODataUriParser query) + public static IFindFluent ContentTake(this IFindFluent cursor, Query query) { - return cursor.Take(query, 200, 20); + return cursor.Take(query); } - public static IFindFluent ContentSkip(this IFindFluent cursor, ODataUriParser query) + public static IFindFluent ContentSkip(this IFindFluent cursor, Query query) { return cursor.Skip(query); } - public static FilterDefinition BuildQuery(ODataUriParser query, Guid schemaId, Status[] status, PropertyCalculator propertyCalculator) + public static FilterDefinition BuildQuery(Query query, Guid schemaId, Status[] status) { var filters = new List> { - Filter.Eq(x => x.SchemaIdId, schemaId), - Filter.In(x => x.Status, status), - Filter.Eq(x => x.IsDeleted, false) + Filter.Eq(x => x.IndexedSchemaId, schemaId) }; - var filter = query.BuildFilter(propertyCalculator); + if (status != null) + { + filters.Add(Filter.Ne(x => x.IsDeleted, true)); + filters.Add(Filter.In(x => x.Status, status)); + } + + var filter = query.BuildFilter(); if (filter.Filter != null) { diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/History/MongoHistoryEventRepository.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/History/MongoHistoryEventRepository.cs index 55ce5a471..fb40a3062 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/History/MongoHistoryEventRepository.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/History/MongoHistoryEventRepository.cs @@ -55,13 +55,15 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.History protected override async Task SetupCollectionAsync(IMongoCollection collection) { await collection.Indexes.CreateOneAsync( - Index - .Ascending(x => x.AppId) - .Ascending(x => x.Channel) - .Descending(x => x.Created) - .Descending(x => x.Version)); + new CreateIndexModel( + Index + .Ascending(x => x.AppId) + .Ascending(x => x.Channel) + .Descending(x => x.Created) + .Descending(x => x.Version))); - await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.Created), new CreateIndexOptions { ExpireAfter = TimeSpan.FromDays(365) }); + await collection.Indexes.CreateOneAsync( + new CreateIndexModel(Index.Ascending(x => x.Created), new CreateIndexOptions { ExpireAfter = TimeSpan.FromDays(365) })); } public async Task> QueryByChannelAsync(Guid appId, string channelPrefix, int count) @@ -110,5 +112,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.History } } } + + public Task RemoveAsync(Guid appId) + { + return Collection.DeleteManyAsync(x => x.AppId == appId); + } } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/History/ParsedHistoryEvent.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/History/ParsedHistoryEvent.cs index 2ac151ee8..156a32822 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/History/ParsedHistoryEvent.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/History/ParsedHistoryEvent.cs @@ -11,8 +11,6 @@ using NodaTime; using Squidex.Domain.Apps.Entities.History; using Squidex.Infrastructure; -#pragma warning disable RECS0029 // Warns about property or indexer setters and event adders or removers that do not use the value parameter - namespace Squidex.Domain.Apps.Entities.MongoDb.History { internal sealed class ParsedHistoryEvent : IHistoryEventEntity @@ -23,19 +21,21 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.History public Guid Id { get { return inner.Id; } - set { } + } + + public Guid EventId + { + get { return inner.Id; } } public Instant Created { get { return inner.Created; } - set { } } public Instant LastModified { get { return inner.LastModified; } - set { } } public RefToken Actor @@ -43,11 +43,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.History get { return inner.Actor; } } - public Guid EventId - { - get { return inner.Id; } - } - public long Version { get { return inner.Version; } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEntity.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEntity.cs deleted file mode 100644 index 962c632aa..000000000 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEntity.cs +++ /dev/null @@ -1,41 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using MongoDB.Bson; -using MongoDB.Bson.Serialization.Attributes; -using Squidex.Domain.Apps.Entities.Rules.State; -using Squidex.Infrastructure.MongoDb; - -namespace Squidex.Domain.Apps.Entities.MongoDb.Rules -{ - public sealed class MongoRuleEntity : IVersionedEntity - { - [BsonId] - [BsonElement] - [BsonRepresentation(BsonType.String)] - public Guid Id { get; set; } - - [BsonElement] - [BsonRequired] - [BsonRepresentation(BsonType.String)] - public Guid AppId { get; set; } - - [BsonElement] - [BsonRequired] - [BsonJson] - public RuleState State { get; set; } - - [BsonElement] - [BsonRequired] - public long Version { get; set; } - - [BsonElement] - [BsonRequired] - public bool IsDeleted { get; set; } - } -} diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs index 468d8d831..b775e9aba 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs @@ -34,9 +34,12 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Rules protected override async Task SetupCollectionAsync(IMongoCollection collection) { - await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.NextAttempt)); - await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.AppId).Descending(x => x.Created)); - await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.Expires), new CreateIndexOptions { ExpireAfter = TimeSpan.Zero }); + await collection.Indexes.CreateOneAsync( + new CreateIndexModel(Index.Ascending(x => x.NextAttempt))); + await collection.Indexes.CreateOneAsync( + new CreateIndexModel(Index.Ascending(x => x.AppId).Descending(x => x.Created))); + await collection.Indexes.CreateOneAsync( + new CreateIndexModel(Index.Ascending(x => x.Expires), new CreateIndexOptions { ExpireAfter = TimeSpan.Zero })); } public Task QueryPendingAsync(Instant now, Func callback, CancellationToken ct = default(CancellationToken)) @@ -62,9 +65,14 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Rules return ruleEvent; } + public Task RemoveAsync(Guid appId) + { + return Collection.DeleteManyAsync(x => x.AppId == appId); + } + public async Task CountByAppAsync(Guid appId) { - return (int)await Collection.CountAsync(x => x.AppId == appId); + return (int)await Collection.CountDocumentsAsync(x => x.AppId == appId); } public Task EnqueueAsync(Guid id, Instant nextAttempt) diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleRepository.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleRepository.cs deleted file mode 100644 index 6ab0b1f3b..000000000 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleRepository.cs +++ /dev/null @@ -1,45 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using MongoDB.Driver; -using Squidex.Domain.Apps.Entities.Rules.Repositories; -using Squidex.Infrastructure.MongoDb; - -namespace Squidex.Domain.Apps.Entities.MongoDb.Rules -{ - public sealed partial class MongoRuleRepository : MongoRepositoryBase, IRuleRepository - { - public MongoRuleRepository(IMongoDatabase database) - : base(database) - { - } - - protected override string CollectionName() - { - return "States_Rules"; - } - - protected override async Task SetupCollectionAsync(IMongoCollection collection) - { - await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.AppId)); - await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.IsDeleted)); - } - - public async Task> QueryRuleIdsAsync(Guid appId) - { - var ruleEntities = - await Collection.Find(x => x.AppId == appId && !x.IsDeleted).Only(x => x.Id) - .ToListAsync(); - - return ruleEntities.Select(x => Guid.Parse(x["_id"].AsString)).ToList(); - } - } -} diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleRepository_SnapshotStore.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleRepository_SnapshotStore.cs deleted file mode 100644 index cd8a2ee02..000000000 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleRepository_SnapshotStore.cs +++ /dev/null @@ -1,42 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Threading.Tasks; -using MongoDB.Driver; -using Squidex.Domain.Apps.Entities.Rules.State; -using Squidex.Infrastructure; -using Squidex.Infrastructure.MongoDb; -using Squidex.Infrastructure.States; - -namespace Squidex.Domain.Apps.Entities.MongoDb.Rules -{ - public sealed partial class MongoRuleRepository : ISnapshotStore - { - public async Task<(RuleState Value, long Version)> ReadAsync(Guid key) - { - var existing = - await Collection.Find(x => x.Id == key) - .FirstOrDefaultAsync(); - - if (existing != null) - { - return (existing.State, existing.Version); - } - - return (null, EtagVersion.NotFound); - } - - public Task WriteAsync(Guid key, RuleState value, long oldVersion, long newVersion) - { - return Collection.UpsertVersionedAsync(key, oldVersion, newVersion, u => u - .Set(x => x.State, value) - .Set(x => x.AppId, value.AppId.Id) - .Set(x => x.IsDeleted, value.IsDeleted)); - } - } -} diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Schemas/MongoSchemaEntity.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Schemas/MongoSchemaEntity.cs deleted file mode 100644 index 100986972..000000000 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Schemas/MongoSchemaEntity.cs +++ /dev/null @@ -1,45 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using MongoDB.Bson; -using MongoDB.Bson.Serialization.Attributes; -using Squidex.Domain.Apps.Entities.Schemas.State; -using Squidex.Infrastructure.MongoDb; - -namespace Squidex.Domain.Apps.Entities.MongoDb.Schemas -{ - public sealed class MongoSchemaEntity : IVersionedEntity - { - [BsonId] - [BsonElement] - [BsonRepresentation(BsonType.String)] - public Guid Id { get; set; } - - [BsonElement] - [BsonRequired] - [BsonRepresentation(BsonType.String)] - public Guid AppId { get; set; } - - [BsonElement] - [BsonRequired] - [BsonJson] - public SchemaState State { get; set; } - - [BsonElement] - [BsonRequired] - public string Name { get; set; } - - [BsonElement] - [BsonRequired] - public long Version { get; set; } - - [BsonElement] - [BsonRequired] - public bool IsDeleted { get; set; } - } -} diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Schemas/MongoSchemaRepository.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Schemas/MongoSchemaRepository.cs deleted file mode 100644 index df56da8b8..000000000 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Schemas/MongoSchemaRepository.cs +++ /dev/null @@ -1,54 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using MongoDB.Driver; -using Squidex.Domain.Apps.Entities.Schemas.Repositories; -using Squidex.Infrastructure.MongoDb; - -namespace Squidex.Domain.Apps.Entities.MongoDb.Schemas -{ - public sealed partial class MongoSchemaRepository : MongoRepositoryBase, ISchemaRepository - { - public MongoSchemaRepository(IMongoDatabase database) - : base(database) - { - } - - protected override string CollectionName() - { - return "States_Schemas"; - } - - protected override async Task SetupCollectionAsync(IMongoCollection collection) - { - await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.AppId).Ascending(x => x.IsDeleted)); - await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.AppId).Ascending(x => x.Name).Ascending(x => x.IsDeleted)); - } - - public async Task FindSchemaIdAsync(Guid appId, string name) - { - var schemaEntity = - await Collection.Find(x => x.AppId == appId && x.Name == name && !x.IsDeleted).Only(x => x.Id).SortByDescending(x => x.Version) - .FirstOrDefaultAsync(); - - return schemaEntity != null ? Guid.Parse(schemaEntity["_id"].AsString) : Guid.Empty; - } - - public async Task> QuerySchemaIdsAsync(Guid appId) - { - var schemaEntities = - await Collection.Find(x => x.AppId == appId && !x.IsDeleted).Only(x => x.Id) - .ToListAsync(); - - return schemaEntities.Select(x => Guid.Parse(x["_id"].AsString)).ToList(); - } - } -} diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Schemas/MongoSchemaRepository_SnapshotStore.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Schemas/MongoSchemaRepository_SnapshotStore.cs deleted file mode 100644 index a23899a7a..000000000 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Schemas/MongoSchemaRepository_SnapshotStore.cs +++ /dev/null @@ -1,43 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Threading.Tasks; -using MongoDB.Driver; -using Squidex.Domain.Apps.Entities.Schemas.State; -using Squidex.Infrastructure; -using Squidex.Infrastructure.MongoDb; -using Squidex.Infrastructure.States; - -namespace Squidex.Domain.Apps.Entities.MongoDb.Schemas -{ - public sealed partial class MongoSchemaRepository : ISnapshotStore - { - public async Task<(SchemaState Value, long Version)> ReadAsync(Guid key) - { - var existing = - await Collection.Find(x => x.Id == key) - .FirstOrDefaultAsync(); - - if (existing != null) - { - return (existing.State, existing.Version); - } - - return (null, EtagVersion.NotFound); - } - - public Task WriteAsync(Guid key, SchemaState value, long oldVersion, long newVersion) - { - return Collection.UpsertVersionedAsync(key, oldVersion, newVersion, u => u - .Set(x => x.State, value) - .Set(x => x.AppId, value.AppId.Id) - .Set(x => x.Name, value.Name) - .Set(x => x.IsDeleted, value.IsDeleted)); - } - } -} diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj b/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj index 1095818b8..046841149 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj @@ -15,11 +15,11 @@ - - - - - + + + + + ..\..\Squidex.ruleset diff --git a/src/Squidex.Domain.Apps.Entities/AppProvider.cs b/src/Squidex.Domain.Apps.Entities/AppProvider.cs index 7439e4a1e..394cb55c4 100644 --- a/src/Squidex.Domain.Apps.Entities/AppProvider.cs +++ b/src/Squidex.Domain.Apps.Entities/AppProvider.cs @@ -9,143 +9,201 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Orleans; using Squidex.Domain.Apps.Entities.Apps; -using Squidex.Domain.Apps.Entities.Apps.Repositories; +using Squidex.Domain.Apps.Entities.Apps.Indexes; using Squidex.Domain.Apps.Entities.Rules; -using Squidex.Domain.Apps.Entities.Rules.Repositories; +using Squidex.Domain.Apps.Entities.Rules.Indexes; using Squidex.Domain.Apps.Entities.Schemas; -using Squidex.Domain.Apps.Entities.Schemas.Repositories; +using Squidex.Domain.Apps.Entities.Schemas.Indexes; using Squidex.Infrastructure; -using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.States; +using Squidex.Infrastructure.Caching; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Orleans; namespace Squidex.Domain.Apps.Entities { public sealed class AppProvider : IAppProvider { - private readonly IAppRepository appRepository; - private readonly IRuleRepository ruleRepository; - private readonly ISchemaRepository schemaRepository; - private readonly IStateFactory stateFactory; - - public AppProvider( - IAppRepository appRepository, - ISchemaRepository schemaRepository, - IStateFactory stateFactory, - IRuleRepository ruleRepository) + private readonly IGrainFactory grainFactory; + private readonly ILocalCache localCache; + + public AppProvider(IGrainFactory grainFactory, ILocalCache localCache) { - Guard.NotNull(appRepository, nameof(appRepository)); - Guard.NotNull(schemaRepository, nameof(schemaRepository)); - Guard.NotNull(stateFactory, nameof(stateFactory)); - Guard.NotNull(ruleRepository, nameof(ruleRepository)); - - this.appRepository = appRepository; - this.schemaRepository = schemaRepository; - this.stateFactory = stateFactory; - this.ruleRepository = ruleRepository; + Guard.NotNull(grainFactory, nameof(grainFactory)); + Guard.NotNull(localCache, nameof(localCache)); + + this.grainFactory = grainFactory; + + this.localCache = localCache; } - public async Task<(IAppEntity, ISchemaEntity)> GetAppWithSchemaAsync(Guid appId, Guid id) + public Task<(IAppEntity, ISchemaEntity)> GetAppWithSchemaAsync(Guid appId, Guid id) { - var app = await stateFactory.GetSingleAsync(appId); - - if (!IsFound(app)) + return localCache.GetOrCreateAsync($"GetAppWithSchemaAsync({appId}, {id})", async () => { - return (null, null); - } + using (Profiler.TraceMethod()) + { + var app = await grainFactory.GetGrain(appId).GetStateAsync(); - var schema = await stateFactory.GetSingleAsync(id); + if (!IsExisting(app)) + { + return (null, null); + } - if (!IsFound(schema) || schema.Snapshot.IsDeleted) - { - return (null, null); - } + var schema = await GetSchemaAsync(appId, id, false); + + if (schema == null) + { + return (null, null); + } - return (app.Snapshot, schema.Snapshot); + return (app.Value, schema); + } + }); } - public async Task GetAppAsync(string appName) + public Task GetAppAsync(string appName) { - var appId = await GetAppIdAsync(appName); - - if (appId == Guid.Empty) + return localCache.GetOrCreateAsync($"GetAppAsync({appName})", async () => { - return null; - } + using (Profiler.TraceMethod()) + { + var appId = await GetAppIdAsync(appName); - return (await stateFactory.GetSingleAsync(appId)).Snapshot; - } + if (appId == Guid.Empty) + { + return null; + } - public async Task GetSchemaAsync(Guid appId, string name) - { - var schemaId = await GetSchemaIdAsync(appId, name); + var app = await grainFactory.GetGrain(appId).GetStateAsync(); - if (schemaId == Guid.Empty) - { - return null; - } + if (!IsExisting(app)) + { + return null; + } - return (await stateFactory.GetSingleAsync(schemaId)).Snapshot; + return app.Value; + } + }); } - public async Task GetSchemaAsync(Guid appId, Guid id, bool allowDeleted = false) + public Task GetSchemaAsync(Guid appId, string name) { - var schema = await stateFactory.GetSingleAsync(id); - - if (!IsFound(schema) || (schema.Snapshot.IsDeleted && !allowDeleted) || schema.Snapshot.AppId.Id != appId) + return localCache.GetOrCreateAsync($"GetSchemaAsync({appId}, {name})", async () => { - return null; - } + using (Profiler.TraceMethod("GetSchemaAsyncByName")) + { + var schemaId = await GetSchemaIdAsync(appId, name); + + if (schemaId == Guid.Empty) + { + return null; + } + + return await GetSchemaAsync(appId, schemaId, false); + } + }); + } - return schema.Snapshot; + public Task GetSchemaAsync(Guid appId, Guid id, bool allowDeleted = false) + { + return localCache.GetOrCreateAsync($"GetSchemaAsync({appId}, {id}, {allowDeleted})", async () => + { + using (Profiler.TraceMethod("GetSchemaAsyncById")) + { + var schema = await grainFactory.GetGrain(id).GetStateAsync(); + + if (!IsExisting(schema, allowDeleted) || schema.Value.AppId.Id != appId) + { + return null; + } + + return schema.Value; + } + }); } - public async Task> GetSchemasAsync(Guid appId) + public Task> GetSchemasAsync(Guid appId) { - var ids = await schemaRepository.QuerySchemaIdsAsync(appId); + return localCache.GetOrCreateAsync($"GetSchemasAsync({appId})", async () => + { + using (Profiler.TraceMethod()) + { + var ids = await grainFactory.GetGrain(appId).GetSchemaIdsAsync(); - var schemas = - await Task.WhenAll( - ids.Select(id => stateFactory.GetSingleAsync(id))); + var schemas = + await Task.WhenAll( + ids.Select(id => grainFactory.GetGrain(id).GetStateAsync())); - return schemas.Where(IsFound).Select(s => (ISchemaEntity)s.Snapshot).ToList(); + return schemas.Where(s => IsFound(s.Value)).Select(s => s.Value).ToList(); + } + }); } - public async Task> GetRulesAsync(Guid appId) + public Task> GetRulesAsync(Guid appId) { - var ids = await ruleRepository.QueryRuleIdsAsync(appId); + return localCache.GetOrCreateAsync($"GetRulesAsync({appId})", async () => + { + using (Profiler.TraceMethod()) + { + var ids = await grainFactory.GetGrain(appId).GetRuleIdsAsync(); - var rules = - await Task.WhenAll( - ids.Select(id => stateFactory.GetSingleAsync(id))); + var rules = + await Task.WhenAll( + ids.Select(id => grainFactory.GetGrain(id).GetStateAsync())); - return rules.Where(IsFound).Select(r => (IRuleEntity)r.Snapshot).ToList(); + return rules.Where(r => IsFound(r.Value)).Select(r => r.Value).ToList(); + } + }); } - public async Task> GetUserApps(string userId) + public Task> GetUserApps(string userId) { - var ids = await appRepository.QueryUserAppIdsAsync(userId); + return localCache.GetOrCreateAsync($"GetUserApps({userId})", async () => + { + using (Profiler.TraceMethod()) + { + var ids = await grainFactory.GetGrain(userId).GetAppIdsAsync(); - var apps = - await Task.WhenAll( - ids.Select(id => stateFactory.GetSingleAsync(id))); + var apps = + await Task.WhenAll( + ids.Select(id => grainFactory.GetGrain(id).GetStateAsync())); - return apps.Where(IsFound).Select(a => (IAppEntity)a.Snapshot).ToList(); + return apps.Where(a => IsFound(a.Value)).Select(a => a.Value).ToList(); + } + }); } - private Task GetAppIdAsync(string name) + private async Task GetAppIdAsync(string name) { - return appRepository.FindAppIdByNameAsync(name); + using (Profiler.TraceMethod()) + { + return await grainFactory.GetGrain(SingleGrain.Id).GetAppIdAsync(name); + } } private async Task GetSchemaIdAsync(Guid appId, string name) { - return await schemaRepository.FindSchemaIdAsync(appId, name); + using (Profiler.TraceMethod()) + { + return await grainFactory.GetGrain(appId).GetSchemaIdAsync(name); + } + } + + private static bool IsFound(IEntityWithVersion entity) + { + return entity.Version > EtagVersion.Empty; + } + + private static bool IsExisting(J app) + { + return IsFound(app.Value) && !app.Value.IsArchived; } - private static bool IsFound(IDomainObjectGrain app) + private static bool IsExisting(J schema, bool allowDeleted) { - return app.Version > EtagVersion.Empty; + return IsFound(schema.Value) && (!schema.Value.IsDeleted || allowDeleted); } } } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs index 50a342d7e..1efd1e404 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs @@ -18,16 +18,17 @@ using Squidex.Domain.Apps.Events.Apps; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.States; using Squidex.Shared.Users; namespace Squidex.Domain.Apps.Entities.Apps { - public class AppGrain : DomainObjectGrain + public sealed class AppGrain : SquidexDomainObjectGrain, IAppGrain { private readonly InitialPatterns initialPatterns; - private readonly IAppProvider appProvider; private readonly IAppPlansProvider appPlansProvider; private readonly IAppPlanBillingManager appPlansBillingManager; private readonly IUserResolver userResolver; @@ -35,43 +36,45 @@ namespace Squidex.Domain.Apps.Entities.Apps public AppGrain( InitialPatterns initialPatterns, IStore store, - IAppProvider appProvider, + ISemanticLog log, IAppPlansProvider appPlansProvider, IAppPlanBillingManager appPlansBillingManager, IUserResolver userResolver) - : base(store) + : base(store, log) { Guard.NotNull(initialPatterns, nameof(initialPatterns)); - Guard.NotNull(appProvider, nameof(appProvider)); Guard.NotNull(userResolver, nameof(userResolver)); Guard.NotNull(appPlansProvider, nameof(appPlansProvider)); Guard.NotNull(appPlansBillingManager, nameof(appPlansBillingManager)); this.userResolver = userResolver; - this.appProvider = appProvider; this.appPlansProvider = appPlansProvider; this.appPlansBillingManager = appPlansBillingManager; this.initialPatterns = initialPatterns; } - public override Task ExecuteAsync(IAggregateCommand command) + protected override Task ExecuteAsync(IAggregateCommand command) { + VerifyNotArchived(); + switch (command) { case CreateApp createApp: - return CreateAsync(createApp, async c => + return CreateAsync(createApp, c => { - await GuardApp.CanCreate(c, appProvider); + GuardApp.CanCreate(c); Create(c); }); case AssignContributor assigneContributor: - return UpdateAsync(assigneContributor, async c => + return UpdateReturnAsync(assigneContributor, async c => { await GuardAppContributors.CanAssign(Snapshot.Contributors, c, userResolver, appPlansProvider.GetPlan(Snapshot.Plan?.PlanId)); AssignContributor(c); + + return EntityCreatedResult.Create(c.ContributorId, Version); }); case RemoveContributor removeContributor: @@ -178,6 +181,14 @@ namespace Squidex.Domain.Apps.Entities.Apps } }); + case ArchiveApp archiveApp: + return UpdateAsync(archiveApp, async c => + { + await appPlansBillingManager.ChangePlanAsync(c.Actor.Identifier, Snapshot.Id, Snapshot.Name, null); + + ArchiveApp(c); + }); + default: throw new NotSupportedException(); } @@ -185,7 +196,7 @@ namespace Squidex.Domain.Apps.Entities.Apps public void Create(CreateApp command) { - var appId = new NamedId(command.AppId, command.Name); + var appId = NamedId.Of(command.AppId, command.Name); var events = new List { @@ -276,11 +287,24 @@ namespace Squidex.Domain.Apps.Entities.Apps RaiseEvent(SimpleMapper.Map(command, new AppPatternUpdated())); } + public void ArchiveApp(ArchiveApp command) + { + RaiseEvent(SimpleMapper.Map(command, new AppArchived())); + } + + private void VerifyNotArchived() + { + if (Snapshot.IsArchived) + { + throw new DomainException("App has already been archived."); + } + } + private void RaiseEvent(AppEvent @event) { if (@event.AppId == null) { - @event.AppId = new NamedId(Snapshot.Id, Snapshot.Name); + @event.AppId = NamedId.Of(Snapshot.Id, Snapshot.Name); } RaiseEvent(Envelope.Create(@event)); @@ -306,9 +330,14 @@ namespace Squidex.Domain.Apps.Entities.Apps return new AppContributorAssigned { ContributorId = actor.Identifier, Permission = AppContributorPermission.Owner }; } - public override void ApplyEvent(Envelope @event) + protected override AppState OnEvent(Envelope @event) + { + return Snapshot.Apply(@event); + } + + public Task> GetStateAsync() { - ApplySnapshot(Snapshot.Apply(@event)); + return J.AsTask(Snapshot); } } } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs index e722f8d06..e8aee083d 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs @@ -20,7 +20,7 @@ namespace Squidex.Domain.Apps.Entities.Apps : base(typeNameRegistry) { AddEventMessage( - "assigned {user:[Contributor]} as [Permission]"); + "assigned {user:[Contributor]} as {[Permission]}"); AddEventMessage( "removed {user:[Contributor]} from app"); @@ -48,6 +48,15 @@ namespace Squidex.Domain.Apps.Entities.Apps AddEventMessage( "changed master language to {[Language]}"); + + AddEventMessage( + "added pattern {[Name]}"); + + AddEventMessage( + "deleted pattern {[PatternId]}"); + + AddEventMessage( + "updated pattern {[Name]}"); } protected Task On(AppContributorRemoved @event) @@ -131,6 +140,33 @@ namespace Squidex.Domain.Apps.Entities.Apps .AddParameter("Language", @event.Language)); } + protected Task On(AppPatternAdded @event) + { + const string channel = "settings.patterns"; + + return Task.FromResult( + ForEvent(@event, channel) + .AddParameter("Name", @event.Name)); + } + + protected Task On(AppPatternUpdated @event) + { + const string channel = "settings.patterns"; + + return Task.FromResult( + ForEvent(@event, channel) + .AddParameter("Name", @event.Name)); + } + + protected Task On(AppPatternDeleted @event) + { + const string channel = "settings.patterns"; + + return Task.FromResult( + ForEvent(@event, channel) + .AddParameter("PatternId", @event.PatternId)); + } + protected override Task CreateEventCoreAsync(Envelope @event) { return this.DispatchFuncAsync(@event.Payload, (HistoryEventToStore)null); diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs new file mode 100644 index 000000000..ea1e17408 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs @@ -0,0 +1,117 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Orleans; +using Squidex.Infrastructure.States; + +namespace Squidex.Domain.Apps.Entities.Apps +{ + public sealed class AppUISettingsGrain : GrainOfGuid, IAppUISettingsGrain + { + private readonly IStore store; + private IPersistence persistence; + private State state = new State(); + + [CollectionName("UISettings")] + public sealed class State + { + public JObject Settings { get; set; } = new JObject(); + } + + public AppUISettingsGrain(IStore store) + { + Guard.NotNull(store, nameof(store)); + + this.store = store; + } + + public override Task OnActivateAsync(Guid key) + { + persistence = store.WithSnapshots(GetType(), key, x => state = x); + + return persistence.ReadAsync(); + } + + public Task> GetAsync() + { + return Task.FromResult(state.Settings.AsJ()); + } + + public Task SetAsync(J settings) + { + state.Settings = settings; + + return persistence.WriteSnapshotAsync(state); + } + + public Task SetAsync(string path, J value) + { + var container = GetContainer(path, out var key); + + if (container == null) + { + throw new InvalidOperationException("Path does not lead to an object."); + } + + container[key] = value; + + return persistence.WriteSnapshotAsync(state); + } + + public Task RemoveAsync(string path) + { + var container = GetContainer(path, out var key); + + if (container != null) + { + container.Remove(key); + } + + return persistence.WriteSnapshotAsync(state); + } + + private JObject GetContainer(string path, out string key) + { + Guard.NotNullOrEmpty(path, nameof(path)); + + var segments = path.Split('.'); + + key = segments[segments.Length - 1]; + + var current = state.Settings; + + if (segments.Length > 1) + { + foreach (var segment in segments.Take(segments.Length - 1)) + { + if (!current.TryGetValue(segment, out var temp)) + { + temp = new JObject(); + + current[segment] = temp; + } + + if (temp is JObject next) + { + current = next; + } + else + { + return null; + } + } + } + + return current; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs b/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs new file mode 100644 index 000000000..1cc342591 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs @@ -0,0 +1,201 @@ +// ========================================================================== +// 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 Newtonsoft.Json.Linq; +using Orleans; +using Squidex.Domain.Apps.Entities.Apps.Indexes; +using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Domain.Apps.Events; +using Squidex.Domain.Apps.Events.Apps; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Orleans; +using Squidex.Shared.Users; + +namespace Squidex.Domain.Apps.Entities.Apps +{ + public sealed class BackupApps : BackupHandler + { + private const string UsersFile = "Users.json"; + private const string SettingsFile = "Settings.json"; + private readonly IGrainFactory grainFactory; + private readonly IUserResolver userResolver; + private readonly IAppsByNameIndex appsByNameIndex; + private readonly HashSet contributors = new HashSet(); + private Dictionary usersWithEmail = new Dictionary(); + private Dictionary userMapping = new Dictionary(); + private bool isReserved; + private string appName; + + public override string Name { get; } = "Apps"; + + public BackupApps(IGrainFactory grainFactory, IUserResolver userResolver) + { + Guard.NotNull(grainFactory, nameof(grainFactory)); + Guard.NotNull(userResolver, nameof(userResolver)); + + this.grainFactory = grainFactory; + + this.userResolver = userResolver; + + appsByNameIndex = grainFactory.GetGrain(SingleGrain.Id); + } + + public override async Task BackupEventAsync(Envelope @event, Guid appId, BackupWriter writer) + { + if (@event.Payload is AppContributorAssigned appContributorAssigned) + { + var userId = appContributorAssigned.ContributorId; + + if (!usersWithEmail.ContainsKey(userId)) + { + var user = await userResolver.FindByIdOrEmailAsync(userId); + + if (user != null) + { + usersWithEmail.Add(userId, user.Email); + } + } + } + } + + public override async Task BackupAsync(Guid appId, BackupWriter writer) + { + await WriteUsersAsync(writer); + await WriteSettingsAsync(writer, appId); + } + + public override async Task RestoreEventAsync(Envelope @event, Guid appId, BackupReader reader, RefToken actor) + { + switch (@event.Payload) + { + case AppCreated appCreated: + { + appName = appCreated.Name; + + await ResolveUsersAsync(reader, actor); + await ReserveAppAsync(appId); + + break; + } + + case AppContributorAssigned contributorAssigned: + { + if (!userMapping.TryGetValue(contributorAssigned.ContributorId, out var user) || user.Equals(actor)) + { + return false; + } + + contributorAssigned.ContributorId = user.Identifier; + contributors.Add(contributorAssigned.ContributorId); + break; + } + + case AppContributorRemoved contributorRemoved: + { + if (!userMapping.TryGetValue(contributorRemoved.ContributorId, out var user) || user.Equals(actor)) + { + return false; + } + + contributorRemoved.ContributorId = user.Identifier; + contributors.Remove(contributorRemoved.ContributorId); + break; + } + } + + if (@event.Payload is SquidexEvent squidexEvent) + { + squidexEvent.Actor = MapUser(squidexEvent.Actor.Identifier, actor); + } + + return true; + } + + public override Task RestoreAsync(Guid appId, BackupReader reader) + { + return ReadSettingsAsync(reader, appId); + } + + private async Task ReserveAppAsync(Guid appId) + { + if (!(isReserved = await appsByNameIndex.ReserveAppAsync(appId, appName))) + { + throw new BackupRestoreException("The app id or name is not available."); + } + } + + public override async Task CleanupRestoreAsync(Guid appId) + { + if (isReserved) + { + await appsByNameIndex.ReserveAppAsync(appId, appName); + } + } + + private RefToken MapUser(string userId, RefToken fallback) + { + return userMapping.GetOrAdd(userId, fallback); + } + + private async Task ResolveUsersAsync(BackupReader reader, RefToken actor) + { + await ReadUsersAsync(reader); + + foreach (var kvp in usersWithEmail) + { + var user = await userResolver.FindByIdOrEmailAsync(kvp.Value); + + if (user != null) + { + userMapping[kvp.Key] = new RefToken(RefTokenType.Subject, user.Id); + } + } + } + + private async Task ReadUsersAsync(BackupReader reader) + { + var json = await reader.ReadJsonAttachmentAsync(UsersFile); + + usersWithEmail = json.ToObject>(); + } + + private async Task WriteUsersAsync(BackupWriter writer) + { + var json = JObject.FromObject(usersWithEmail); + + await writer.WriteJsonAsync(UsersFile, json); + } + + private async Task WriteSettingsAsync(BackupWriter writer, Guid appId) + { + var json = await grainFactory.GetGrain(appId).GetAsync(); + + await writer.WriteJsonAsync(SettingsFile, json); + } + + private async Task ReadSettingsAsync(BackupReader reader, Guid appId) + { + var json = await reader.ReadJsonAttachmentAsync(SettingsFile); + + await grainFactory.GetGrain(appId).SetAsync((JObject)json); + } + + public override async Task CompleteRestoreAsync(Guid appId, BackupReader reader) + { + await appsByNameIndex.AddAppAsync(appId, appName); + + foreach (var user in contributors) + { + await grainFactory.GetGrain(user).AddAppAsync(appId); + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/ArchiveApp.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/ArchiveApp.cs new file mode 100644 index 000000000..aeb898751 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/ArchiveApp.cs @@ -0,0 +1,13 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Apps.Commands +{ + public sealed class ArchiveApp : AppCommand + { + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs index b54518a66..c03cfd358 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs @@ -13,6 +13,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Commands { public string ContributorId { get; set; } + public bool FromRestore { get; set; } + public AppContributorPermission Permission { get; set; } } } \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/CreateApp.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/CreateApp.cs index d4dc2528b..5a97ddd7f 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Commands/CreateApp.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/CreateApp.cs @@ -6,11 +6,10 @@ // ========================================================================== using System; -using Squidex.Infrastructure.Commands; namespace Squidex.Domain.Apps.Entities.Apps.Commands { - public sealed class CreateApp : AppCommand, IAggregateCommand + public sealed class CreateApp : AppCommand { public string Name { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardApp.cs b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardApp.cs index 81df41e9a..49787bba8 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardApp.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardApp.cs @@ -6,7 +6,6 @@ // ========================================================================== using System; -using System.Threading.Tasks; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Services; @@ -16,20 +15,15 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards { public static class GuardApp { - public static Task CanCreate(CreateApp command, IAppProvider appProvider) + public static void CanCreate(CreateApp command) { Guard.NotNull(command, nameof(command)); - return Validate.It(() => "Cannot create app.", async error => + Validate.It(() => "Cannot create app.", e => { - if (await appProvider.GetAppAsync(command.Name) != null) - { - error(new ValidationError($"An app with name '{command.Name}' already exists", nameof(command.Name))); - } - if (!command.Name.IsSlug()) { - error(new ValidationError("Name must be a valid slug.", nameof(command.Name))); + e("Name must be a valid slug.", nameof(command.Name)); } }); } @@ -38,25 +32,27 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards { Guard.NotNull(command, nameof(command)); - Validate.It(() => "Cannot change plan.", error => + Validate.It(() => "Cannot change plan.", e => { if (string.IsNullOrWhiteSpace(command.PlanId)) { - error(new ValidationError("PlanId is not defined.", nameof(command.PlanId))); + e("Plan id is required.", nameof(command.PlanId)); + return; } - else if (appPlans.GetPlan(command.PlanId) == null) + + if (appPlans.GetPlan(command.PlanId) == null) { - error(new ValidationError("Plan id not available.", nameof(command.PlanId))); + e("A plan with this id does not exist.", nameof(command.PlanId)); } if (!string.IsNullOrWhiteSpace(command.PlanId) && plan != null && !plan.Owner.Equals(command.Actor)) { - error(new ValidationError("Plan can only be changed from current user.")); + e("Plan can only changed from the user who configured the plan initially."); } if (string.Equals(command.PlanId, plan?.PlanId, StringComparison.OrdinalIgnoreCase)) { - error(new ValidationError("App has already this plan.")); + e("App has already this plan."); } }); } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppClients.cs b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppClients.cs index 1069ebee2..68b6454e5 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppClients.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppClients.cs @@ -17,15 +17,15 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards { Guard.NotNull(command, nameof(command)); - Validate.It(() => "Cannot attach client.", error => + Validate.It(() => "Cannot attach client.", e => { if (string.IsNullOrWhiteSpace(command.Id)) { - error(new ValidationError("Client id is required.", nameof(command.Id))); + e("Client id is required.", nameof(command.Id)); } else if (clients.ContainsKey(command.Id)) { - error(new ValidationError("Client id already added.", nameof(command.Id))); + e("A client with the same id already exists."); } }); } @@ -36,11 +36,11 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards GetClientOrThrow(clients, command.Id); - Validate.It(() => "Cannot revoke client.", error => + Validate.It(() => "Cannot revoke client.", e => { if (string.IsNullOrWhiteSpace(command.Id)) { - error(new ValidationError("Client id is required.", nameof(command.Id))); + e("Client id is required.", nameof(command.Id)); } }); } @@ -51,41 +51,43 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards var client = GetClientOrThrow(clients, command.Id); - Validate.It(() => "Cannot revoke client.", error => + Validate.It(() => "Cannot update client.", e => { if (string.IsNullOrWhiteSpace(command.Id)) { - error(new ValidationError("Client id is required.", nameof(command.Id))); + e("Client id is required.", nameof(command.Id)); } if (string.IsNullOrWhiteSpace(command.Name) && command.Permission == null) { - error(new ValidationError("Either name or permission is required.", nameof(command.Name), nameof(command.Permission))); + e("Either name or permission must be defined.", nameof(command.Name), nameof(command.Permission)); } if (command.Permission.HasValue && !command.Permission.Value.IsEnumValue()) { - error(new ValidationError("Permission is not valid.", nameof(command.Permission))); + e("Permission is not valid.", nameof(command.Permission)); } - if (client != null) + if (client == null) { - if (!string.IsNullOrWhiteSpace(command.Name) && string.Equals(client.Name, command.Name)) - { - error(new ValidationError("Client already has this name.", nameof(command.Permission))); - } - - if (command.Permission == client.Permission) - { - error(new ValidationError("Client already has this permission.", nameof(command.Permission))); - } + return; + } + + if (!string.IsNullOrWhiteSpace(command.Name) && string.Equals(client.Name, command.Name)) + { + e("Client has already this name.", nameof(command.Name)); + } + + if (command.Permission == client.Permission) + { + e("Client has already this permission.", nameof(command.Permission)); } }); } private static AppClient GetClientOrThrow(AppClients clients, string id) { - if (id == null) + if (string.IsNullOrWhiteSpace(id)) { return null; } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppContributors.cs b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppContributors.cs index dfaedfc24..bdeff29a6 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppContributors.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppContributors.cs @@ -5,7 +5,9 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Linq; +using System.Security; using System.Threading.Tasks; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Entities.Apps.Commands; @@ -21,35 +23,44 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards { Guard.NotNull(command, nameof(command)); - return Validate.It(() => "Cannot assign contributor.", async error => + return Validate.It(() => "Cannot assign contributor.", async e => { if (!command.Permission.IsEnumValue()) { - error(new ValidationError("Permission is not valid.", nameof(command.Permission))); + e("Permission is not valid.", nameof(command.Permission)); } if (string.IsNullOrWhiteSpace(command.ContributorId)) { - error(new ValidationError("Contributor id not assigned.", nameof(command.ContributorId))); + e("Contributor id is required.", nameof(command.ContributorId)); + return; } - else + + var user = await users.FindByIdOrEmailAsync(command.ContributorId); + + if (user == null) { - if (await users.FindByIdAsync(command.ContributorId) == null) - { - error(new ValidationError("Cannot find contributor id.", nameof(command.ContributorId))); - } - else if (contributors.TryGetValue(command.ContributorId, out var existing)) - { - if (existing == command.Permission) - { - error(new ValidationError("Contributor has already this permission.", nameof(command.Permission))); - } - } - else if (plan.MaxContributors == contributors.Count) + throw new DomainObjectNotFoundException(command.ContributorId, "Contributors", typeof(IAppEntity)); + } + + command.ContributorId = user.Id; + + if (string.Equals(command.ContributorId, command.Actor?.Identifier, StringComparison.OrdinalIgnoreCase) && !command.FromRestore) + { + throw new SecurityException("You cannot change your own permission."); + } + + if (contributors.TryGetValue(command.ContributorId, out var existing)) + { + if (existing == command.Permission) { - error(new ValidationError("You have reached the maximum number of contributors for your plan.")); + e("Contributor has already this permission.", nameof(command.Permission)); } } + else if (plan.MaxContributors == contributors.Count) + { + e("You have reached the maximum number of contributors for your plan."); + } }); } @@ -57,18 +68,18 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards { Guard.NotNull(command, nameof(command)); - Validate.It(() => "Cannot remove contributor.", error => + Validate.It(() => "Cannot remove contributor.", e => { if (string.IsNullOrWhiteSpace(command.ContributorId)) { - error(new ValidationError("Contributor id not assigned.", nameof(command.ContributorId))); + e("Contributor id is required.", nameof(command.ContributorId)); } var ownerIds = contributors.Where(x => x.Value == AppContributorPermission.Owner).Select(x => x.Key).ToList(); if (ownerIds.Count == 1 && ownerIds.Contains(command.ContributorId)) { - error(new ValidationError("Cannot remove the only owner.", nameof(command.ContributorId))); + e("Cannot remove the only owner."); } }); diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppLanguages.cs b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppLanguages.cs index bacb27b5b..183249ec3 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppLanguages.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppLanguages.cs @@ -17,15 +17,15 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards { Guard.NotNull(command, nameof(command)); - Validate.It(() => "Cannot add language.", error => + Validate.It(() => "Cannot add language.", e => { if (command.Language == null) { - error(new ValidationError("Language cannot be null.", nameof(command.Language))); + e("Language code is required.", nameof(command.Language)); } else if (languages.Contains(command.Language)) { - error(new ValidationError("Language already added.", nameof(command.Language))); + e("Language has already been added."); } }); } @@ -34,18 +34,18 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards { Guard.NotNull(command, nameof(command)); - var languageConfig = GetLanguageConfigOrThrow(languages, command.Language); + var config = GetConfigOrThrow(languages, command.Language); - Validate.It(() => "Cannot remove language.", error => + Validate.It(() => "Cannot remove language.", e => { if (command.Language == null) { - error(new ValidationError("Language cannot be null.", nameof(command.Language))); + e("Language code is required.", nameof(command.Language)); } - if (languages.Master == languageConfig) + if (languages.Master == config) { - error(new ValidationError("Language config is master.", nameof(command.Language))); + e("Master language cannot be removed."); } }); } @@ -54,34 +54,36 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards { Guard.NotNull(command, nameof(command)); - var languageConfig = GetLanguageConfigOrThrow(languages, command.Language); + var config = GetConfigOrThrow(languages, command.Language); - Validate.It(() => "Cannot update language.", error => + Validate.It(() => "Cannot update language.", e => { if (command.Language == null) { - error(new ValidationError("Language cannot be null.", nameof(command.Language))); + e("Language is required.", nameof(command.Language)); } - if ((languages.Master == languageConfig || command.IsMaster) && command.IsOptional) + if ((languages.Master == config || command.IsMaster) && command.IsOptional) { - error(new ValidationError("Cannot make master language optional.", nameof(command.IsMaster))); + e("Master language cannot be made optional.", nameof(command.IsMaster)); } - if (command.Fallback != null) + if (command.Fallback == null) { - foreach (var fallback in command.Fallback) + return; + } + + foreach (var fallback in command.Fallback) + { + if (!languages.Contains(fallback)) { - if (!languages.Contains(fallback)) - { - error(new ValidationError($"Config does not contain fallback language {fallback}.", nameof(command.Fallback))); - } + e($"App does not have fallback language '{fallback}'.", nameof(command.Fallback)); } } }); } - private static LanguageConfig GetLanguageConfigOrThrow(LanguagesConfig languages, Language language) + private static LanguageConfig GetConfigOrThrow(LanguagesConfig languages, Language language) { if (language == null) { diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppPattern.cs b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppPattern.cs index 6a71461ec..49996f253 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppPattern.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppPattern.cs @@ -18,30 +18,35 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards { Guard.NotNull(command, nameof(command)); - Validate.It(() => "Cannot add pattern.", error => + Validate.It(() => "Cannot add pattern.", e => { + if (command.PatternId == Guid.Empty) + { + e("Id is required.", nameof(command.PatternId)); + } + if (string.IsNullOrWhiteSpace(command.Name)) { - error(new ValidationError("Pattern name can not be empty.", nameof(command.Name))); + e("Name is required.", nameof(command.Name)); } if (patterns.Values.Any(x => x.Name.Equals(command.Name, StringComparison.OrdinalIgnoreCase))) { - error(new ValidationError("Pattern name is already assigned.", nameof(command.Name))); + e("A pattern with the same name already exists."); } if (string.IsNullOrWhiteSpace(command.Pattern)) { - error(new ValidationError("Pattern can not be empty.", nameof(command.Pattern))); + e("Pattern is required.", nameof(command.Pattern)); } else if (!command.Pattern.IsValidRegex()) { - error(new ValidationError("Pattern is not a valid regular expression.", nameof(command.Pattern))); + e("Pattern is not a valid regular expression.", nameof(command.Pattern)); } if (patterns.Values.Any(x => x.Pattern == command.Pattern)) { - error(new ValidationError("Pattern already exists.", nameof(command.Pattern))); + e("This pattern already exists but with another name."); } }); } @@ -65,30 +70,30 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards throw new DomainObjectNotFoundException(command.PatternId.ToString(), typeof(AppPattern)); } - Validate.It(() => "Cannot update pattern.", error => + Validate.It(() => "Cannot update pattern.", e => { if (string.IsNullOrWhiteSpace(command.Name)) { - error(new ValidationError("Pattern name can not be empty.", nameof(command.Name))); + e("Name is required.", nameof(command.Name)); } if (patterns.Any(x => x.Key != command.PatternId && x.Value.Name.Equals(command.Name, StringComparison.OrdinalIgnoreCase))) { - error(new ValidationError("Pattern name is already assigned.", nameof(command.Name))); + e("A pattern with the same name already exists."); } if (string.IsNullOrWhiteSpace(command.Pattern)) { - error(new ValidationError("Pattern can not be empty.", nameof(command.Pattern))); + e("Pattern is required.", nameof(command.Pattern)); } else if (!command.Pattern.IsValidRegex()) { - error(new ValidationError("Pattern is not a valid regular expression.", nameof(command.Pattern))); + e("Pattern is not a valid regular expression.", nameof(command.Pattern)); } if (patterns.Any(x => x.Key != command.PatternId && x.Value.Pattern == command.Pattern)) { - error(new ValidationError("Pattern already exists.", nameof(command.Pattern))); + e("This pattern already exists but with another name."); } }); } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs b/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs index facea56c0..73300f0d2 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs @@ -9,7 +9,11 @@ using Squidex.Domain.Apps.Core.Apps; namespace Squidex.Domain.Apps.Entities.Apps { - public interface IAppEntity : IEntity, IEntityWithVersion + public interface IAppEntity : + IEntity, + IEntityWithCreatedBy, + IEntityWithLastModifiedBy, + IEntityWithVersion { string Name { get; } @@ -22,5 +26,7 @@ namespace Squidex.Domain.Apps.Entities.Apps AppContributors Contributors { get; } LanguagesConfig LanguagesConfig { get; } + + bool IsArchived { get; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/IAppGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/IAppGrain.cs new file mode 100644 index 000000000..d98dd68b8 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/IAppGrain.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Orleans; + +namespace Squidex.Domain.Apps.Entities.Apps +{ + public interface IAppGrain : IDomainObjectGrain + { + Task> GetStateAsync(); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/IAppUISettingsGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/IAppUISettingsGrain.cs new file mode 100644 index 000000000..38fde5c74 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/IAppUISettingsGrain.cs @@ -0,0 +1,25 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using Orleans; +using Squidex.Infrastructure.Orleans; + +namespace Squidex.Domain.Apps.Entities.Apps +{ + public interface IAppUISettingsGrain : IGrainWithGuidKey + { + Task> GetAsync(); + + Task SetAsync(string path, J value); + + Task SetAsync(J settings); + + Task RemoveAsync(string path); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByNameIndexCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByNameIndexCommandMiddleware.cs new file mode 100644 index 000000000..12117ceeb --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByNameIndexCommandMiddleware.cs @@ -0,0 +1,71 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Orleans; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Orleans; + +namespace Squidex.Domain.Apps.Entities.Apps.Indexes +{ + public sealed class AppsByNameIndexCommandMiddleware : ICommandMiddleware + { + private readonly IAppsByNameIndex index; + + public AppsByNameIndexCommandMiddleware(IGrainFactory grainFactory) + { + Guard.NotNull(grainFactory, nameof(grainFactory)); + + index = grainFactory.GetGrain(SingleGrain.Id); + } + + public async Task HandleAsync(CommandContext context, Func next) + { + var createApp = context.Command as CreateApp; + + var isReserved = false; + try + { + if (createApp != null) + { + isReserved = await index.ReserveAppAsync(createApp.AppId, createApp.Name); + + if (!isReserved) + { + var error = new ValidationError("An app with the same name already exists.", nameof(createApp.Name)); + + throw new ValidationException("Cannot create app.", error); + } + } + + await next(); + + if (context.IsCompleted) + { + if (createApp != null) + { + await index.AddAppAsync(createApp.AppId, createApp.Name); + } + else if (context.Command is ArchiveApp archiveApp) + { + await index.RemoveAppAsync(archiveApp.AppId); + } + } + } + finally + { + if (isReserved) + { + await index.RemoveReservationAsync(createApp.AppId, createApp.Name); + } + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByNameIndexGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByNameIndexGrain.cs new file mode 100644 index 000000000..29da56d76 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByNameIndexGrain.cs @@ -0,0 +1,119 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Orleans; +using Squidex.Infrastructure.States; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Domain.Apps.Entities.Apps.Indexes +{ + public sealed class AppsByNameIndexGrain : GrainOfString, IAppsByNameIndex + { + private readonly IStore store; + private readonly HashSet reservedIds = new HashSet(); + private readonly HashSet reservedNames = new HashSet(); + private IPersistence persistence; + private State state = new State(); + + [CollectionName("Index_AppsByName")] + public sealed class State + { + public Dictionary Apps { get; set; } = new Dictionary(); + } + + public AppsByNameIndexGrain(IStore store) + { + Guard.NotNull(store, nameof(store)); + + this.store = store; + } + + public override Task OnActivateAsync(string key) + { + persistence = store.WithSnapshots(key, s => + { + state = s; + }); + + return persistence.ReadAsync(); + } + + public Task RebuildAsync(Dictionary apps) + { + state = new State { Apps = apps }; + + return persistence.WriteSnapshotAsync(state); + } + + public Task ReserveAppAsync(Guid appId, string name) + { + var canReserve = + !state.Apps.ContainsKey(name) && + !state.Apps.Any(x => x.Value == appId) && + !reservedIds.Contains(appId) && + !reservedNames.Contains(name); + + if (canReserve) + { + reservedIds.Add(appId); + reservedNames.Add(name); + } + + return Task.FromResult(canReserve); + } + + public Task RemoveReservationAsync(Guid appId, string name) + { + reservedIds.Remove(appId); + reservedNames.Remove(name); + + return TaskHelper.Done; + } + + public Task AddAppAsync(Guid appId, string name) + { + state.Apps[name] = appId; + + reservedIds.Remove(appId); + reservedNames.Remove(name); + + return persistence.WriteSnapshotAsync(state); + } + + public Task RemoveAppAsync(Guid appId) + { + var name = state.Apps.FirstOrDefault(x => x.Value == appId).Key; + + if (!string.IsNullOrWhiteSpace(name)) + { + state.Apps.Remove(name); + + reservedIds.Remove(appId); + reservedNames.Remove(name); + } + + return persistence.WriteSnapshotAsync(state); + } + + public Task GetAppIdAsync(string appName) + { + state.Apps.TryGetValue(appName, out var appId); + + return Task.FromResult(appId); + } + + public Task> GetAppIdsAsync() + { + return Task.FromResult(state.Apps.Values.ToList()); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByUserIndexCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByUserIndexCommandMiddleware.cs new file mode 100644 index 000000000..3327fc914 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByUserIndexCommandMiddleware.cs @@ -0,0 +1,80 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Orleans; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; + +namespace Squidex.Domain.Apps.Entities.Apps.Indexes +{ + public sealed class AppsByUserIndexCommandMiddleware : ICommandMiddleware + { + private readonly IGrainFactory grainFactory; + + public AppsByUserIndexCommandMiddleware(IGrainFactory grainFactory) + { + Guard.NotNull(grainFactory, nameof(grainFactory)); + + this.grainFactory = grainFactory; + } + + public async Task HandleAsync(CommandContext context, Func next) + { + if (context.IsCompleted) + { + switch (context.Command) + { + case CreateApp createApp: + await Index(GetUserId(createApp)).AddAppAsync(createApp.AppId); + break; + case AssignContributor assignContributor: + await Index(GetUserId(context)).AddAppAsync(assignContributor.AppId); + break; + case RemoveContributor removeContributor: + await Index(GetUserId(removeContributor)).RemoveAppAsync(removeContributor.AppId); + break; + case ArchiveApp archiveApp: + { + var appState = await grainFactory.GetGrain(archiveApp.AppId).GetStateAsync(); + + foreach (var contributorId in appState.Value.Contributors.Keys) + { + await Index(contributorId).RemoveAppAsync(archiveApp.AppId); + } + + break; + } + } + } + + await next(); + } + + private static string GetUserId(RemoveContributor removeContributor) + { + return removeContributor.ContributorId; + } + + private static string GetUserId(CreateApp createApp) + { + return createApp.Actor.Identifier; + } + + private static string GetUserId(CommandContext context) + { + return context.Result>().IdOrValue; + } + + private IAppsByUserIndex Index(string id) + { + return grainFactory.GetGrain(id); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByUserIndexGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByUserIndexGrain.cs new file mode 100644 index 000000000..f8275bdc7 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByUserIndexGrain.cs @@ -0,0 +1,73 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Orleans; +using Squidex.Infrastructure.States; + +namespace Squidex.Domain.Apps.Entities.Apps.Indexes +{ + public sealed class AppsByUserIndexGrain : GrainOfString, IAppsByUserIndex + { + private readonly IStore store; + private IPersistence persistence; + private State state = new State(); + + [CollectionName("Index_AppsByUser")] + public sealed class State + { + public HashSet Apps { get; set; } = new HashSet(); + } + + public AppsByUserIndexGrain(IStore store) + { + Guard.NotNull(store, nameof(store)); + + this.store = store; + } + + public override Task OnActivateAsync(string key) + { + persistence = store.WithSnapshots(key, s => + { + state = s; + }); + + return persistence.ReadAsync(); + } + + public Task RebuildAsync(HashSet apps) + { + state = new State { Apps = apps }; + + return persistence.WriteSnapshotAsync(state); + } + + public Task AddAppAsync(Guid appId) + { + state.Apps.Add(appId); + + return persistence.WriteSnapshotAsync(state); + } + + public Task RemoveAppAsync(Guid appId) + { + state.Apps.Remove(appId); + + return persistence.WriteSnapshotAsync(state); + } + + public Task> GetAppIdsAsync() + { + return Task.FromResult(state.Apps.ToList()); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsByNameIndex.cs b/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsByNameIndex.cs new file mode 100644 index 000000000..2580ab694 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsByNameIndex.cs @@ -0,0 +1,31 @@ +// ========================================================================== +// 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 Orleans; + +namespace Squidex.Domain.Apps.Entities.Apps.Indexes +{ + public interface IAppsByNameIndex : IGrainWithStringKey + { + Task ReserveAppAsync(Guid appId, string name); + + Task AddAppAsync(Guid appId, string name); + + Task RemoveAppAsync(Guid appId); + + Task RebuildAsync(Dictionary apps); + + Task RemoveReservationAsync(Guid appId, string name); + + Task GetAppIdAsync(string name); + + Task> GetAppIdsAsync(); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsByUserIndex.cs b/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsByUserIndex.cs new file mode 100644 index 000000000..e769f8803 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsByUserIndex.cs @@ -0,0 +1,25 @@ +// ========================================================================== +// 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 Orleans; + +namespace Squidex.Domain.Apps.Entities.Apps.Indexes +{ + public interface IAppsByUserIndex : IGrainWithStringKey + { + Task AddAppAsync(Guid appId); + + Task RemoveAppAsync(Guid appId); + + Task RebuildAsync(HashSet apps); + + Task> GetAppIdsAsync(); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Repositories/IAppRepository.cs b/src/Squidex.Domain.Apps.Entities/Apps/Repositories/IAppRepository.cs deleted file mode 100644 index c8f1a339e..000000000 --- a/src/Squidex.Domain.Apps.Entities/Apps/Repositories/IAppRepository.cs +++ /dev/null @@ -1,22 +0,0 @@ -// ========================================================================== -// 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.Threading.Tasks; - -namespace Squidex.Domain.Apps.Entities.Apps.Repositories -{ - public interface IAppRepository - { - Task FindAppIdByNameAsync(string name); - - Task> QueryAppIdsAsync(); - - Task> QueryUserAppIdsAsync(string userId); - } -} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Services/IAppLimitsPlan.cs b/src/Squidex.Domain.Apps.Entities/Apps/Services/IAppLimitsPlan.cs index 21bbae9bf..59d0feed4 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Services/IAppLimitsPlan.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Services/IAppLimitsPlan.cs @@ -15,6 +15,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.Services string Costs { get; } + string YearlyCosts { get; } + + string YearlyId { get; } + long MaxApiCalls { get; } long MaxAssetSize { get; } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/ConfigAppLimitsPlan.cs b/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/ConfigAppLimitsPlan.cs index 5f4892e4b..3d568c928 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/ConfigAppLimitsPlan.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/ConfigAppLimitsPlan.cs @@ -15,6 +15,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.Services.Implementations public string Costs { get; set; } + public string YearlyCosts { get; set; } + + public string YearlyId { get; set; } + public long MaxApiCalls { get; set; } public long MaxAssetSize { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/ConfigAppPlansProvider.cs b/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/ConfigAppPlansProvider.cs index 813914f09..83bd9d196 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/ConfigAppPlansProvider.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/ConfigAppPlansProvider.cs @@ -23,15 +23,23 @@ namespace Squidex.Domain.Apps.Entities.Apps.Services.Implementations MaxContributors = -1 }; - private readonly Dictionary plansById; - private readonly List plansList; + private readonly Dictionary plansById = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly List plansList = new List(); public ConfigAppPlansProvider(IEnumerable config) { Guard.NotNull(config, nameof(config)); - plansList = config.Select(c => c.Clone()).OrderBy(x => x.MaxApiCalls).ToList(); - plansById = plansList.ToDictionary(c => c.Id, StringComparer.OrdinalIgnoreCase); + foreach (var plan in config.OrderBy(x => x.MaxApiCalls).Select(x => x.Clone())) + { + plansList.Add(plan); + plansById[plan.Id] = plan; + + if (!string.IsNullOrWhiteSpace(plan.YearlyId) && !string.IsNullOrWhiteSpace(plan.YearlyCosts)) + { + plansById[plan.YearlyId] = plan; + } + } } public IEnumerable GetAvailablePlans() diff --git a/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs b/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs index ab840af50..9a60918c9 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs @@ -9,18 +9,16 @@ using Newtonsoft.Json; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events.Apps; -using Squidex.Infrastructure; using Squidex.Infrastructure.Dispatching; using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.Reflection; +using Squidex.Infrastructure.States; namespace Squidex.Domain.Apps.Entities.Apps.State { - public class AppState : DomainObjectState, - IAppEntity + [CollectionName("Apps")] + public class AppState : DomainObjectState, IAppEntity { - private static readonly LanguagesConfig English = LanguagesConfig.Build(Language.EN); - [JsonProperty] public string Name { get; set; } @@ -37,7 +35,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.State public AppContributors Contributors { get; set; } = AppContributors.Empty; [JsonProperty] - public LanguagesConfig LanguagesConfig { get; set; } = English; + public LanguagesConfig LanguagesConfig { get; set; } = LanguagesConfig.English; + + [JsonProperty] + public bool IsArchived { get; set; } protected void On(AppCreated @event) { @@ -114,6 +115,13 @@ namespace Squidex.Domain.Apps.Entities.Apps.State } } + protected void On(AppArchived @event) + { + Plan = null; + + IsArchived = true; + } + public AppState Apply(Envelope @event) { var payload = (SquidexEvent)@event.Payload; diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/AssetFieldBuilder.cs b/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/AssetFieldBuilder.cs new file mode 100644 index 000000000..6186f354a --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/AssetFieldBuilder.cs @@ -0,0 +1,34 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Schemas.Commands; + +namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders +{ + public class AssetFieldBuilder : FieldBuilder + { + public AssetFieldBuilder(CreateSchemaField field) + : base(field) + { + } + + public AssetFieldBuilder MustBeImage() + { + Properties().MustBeImage = true; + + return this; + } + + public AssetFieldBuilder RequireSingle() + { + Properties().MaxItems = 2; + + return this; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/BooleanFieldBuilder.cs b/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/BooleanFieldBuilder.cs new file mode 100644 index 000000000..657c1a334 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/BooleanFieldBuilder.cs @@ -0,0 +1,27 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Schemas.Commands; + +namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders +{ + public class BooleanFieldBuilder : FieldBuilder + { + public BooleanFieldBuilder(CreateSchemaField field) + : base(field) + { + } + + public BooleanFieldBuilder AsToggle() + { + Properties().Editor = BooleanFieldEditor.Toggle; + + return this; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/DateTimeFieldBuilder.cs b/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/DateTimeFieldBuilder.cs new file mode 100644 index 000000000..0c53d6cc2 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/DateTimeFieldBuilder.cs @@ -0,0 +1,27 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Schemas.Commands; + +namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders +{ + public class DateTimeFieldBuilder : FieldBuilder + { + public DateTimeFieldBuilder(CreateSchemaField field) + : base(field) + { + } + + public DateTimeFieldBuilder AsDateTime() + { + Properties().Editor = DateTimeFieldEditor.DateTime; + + return this; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/FieldBuilder.cs b/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/FieldBuilder.cs new file mode 100644 index 000000000..2d519d752 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/FieldBuilder.cs @@ -0,0 +1,70 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Schemas.Commands; + +namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders +{ + public abstract class FieldBuilder + { + private readonly CreateSchemaField field; + + protected T Properties() where T : FieldProperties + { + return field.Properties as T; + } + + protected FieldBuilder(CreateSchemaField field) + { + this.field = field; + } + + public FieldBuilder Label(string label) + { + field.Properties.Label = label; + + return this; + } + + public FieldBuilder Hints(string hints) + { + field.Properties.Hints = hints; + + return this; + } + + public FieldBuilder Localizable() + { + field.Partitioning = Partitioning.Language.Key; + + return this; + } + + public FieldBuilder Disabled() + { + field.IsDisabled = true; + + return this; + } + + public FieldBuilder Required() + { + field.Properties.IsRequired = true; + + return this; + } + + public FieldBuilder ShowInList() + { + field.Properties.IsListField = true; + + return this; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/JsonFieldBuilder.cs b/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/JsonFieldBuilder.cs new file mode 100644 index 000000000..15c40c38d --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/JsonFieldBuilder.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Entities.Schemas.Commands; + +namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders +{ + public class JsonFieldBuilder : FieldBuilder + { + public JsonFieldBuilder(CreateSchemaField field) + : base(field) + { + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/NumberFieldBuilder.cs b/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/NumberFieldBuilder.cs new file mode 100644 index 000000000..19f30de32 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/NumberFieldBuilder.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Entities.Schemas.Commands; + +namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders +{ + public class NumberFieldBuilder : FieldBuilder + { + public NumberFieldBuilder(CreateSchemaField field) + : base(field) + { + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/SchemaBuilder.cs b/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/SchemaBuilder.cs new file mode 100644 index 000000000..af1e3a7b4 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/SchemaBuilder.cs @@ -0,0 +1,130 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Schemas.Commands; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders +{ + public sealed class SchemaBuilder + { + private readonly CreateSchema command; + + public SchemaBuilder(CreateSchema command) + { + this.command = command; + } + + public static SchemaBuilder Create(string name) + { + return new SchemaBuilder(new CreateSchema + { + Name = name.ToKebabCase(), + Publish = true, + Properties = new SchemaProperties + { + Label = name + } + }); + } + + public SchemaBuilder Singleton() + { + command.Singleton = true; + + return this; + } + + public SchemaBuilder AddAssets(string name, Action configure) + { + var field = AddField(name); + + configure(new AssetFieldBuilder(field)); + + return this; + } + + public SchemaBuilder AddBoolean(string name, Action configure) + { + var field = AddField(name); + + configure(new BooleanFieldBuilder(field)); + + return this; + } + + public SchemaBuilder AddDateTime(string name, Action configure) + { + var field = AddField(name); + + configure(new DateTimeFieldBuilder(field)); + + return this; + } + + public SchemaBuilder AddJson(string name, Action configure) + { + var field = AddField(name); + + configure(new JsonFieldBuilder(field)); + + return this; + } + + public SchemaBuilder AddNumber(string name, Action configure) + { + var field = AddField(name); + + configure(new NumberFieldBuilder(field)); + + return this; + } + + public SchemaBuilder AddString(string name, Action configure) + { + var field = AddField(name); + + configure(new StringFieldBuilder(field)); + + return this; + } + + public SchemaBuilder AddTags(string name, Action configure) + { + var field = AddField(name); + + configure(new TagsFieldBuilder(field)); + + return this; + } + + private CreateSchemaField AddField(string name) where T : FieldProperties, new() + { + var field = new CreateSchemaField + { + Name = name.ToCamelCase(), + Properties = new T + { + Label = name + } + }; + + command.Fields = command.Fields ?? new List(); + command.Fields.Add(field); + + return field; + } + + public CreateSchema Build() + { + return command; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/StringFieldBuilder.cs b/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/StringFieldBuilder.cs new file mode 100644 index 000000000..4a798f72b --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/StringFieldBuilder.cs @@ -0,0 +1,59 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Immutable; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Schemas.Commands; + +namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders +{ + public class StringFieldBuilder : FieldBuilder + { + public StringFieldBuilder(CreateSchemaField field) + : base(field) + { + } + + public StringFieldBuilder AsTextArea() + { + Properties().Editor = StringFieldEditor.TextArea; + + return this; + } + + public StringFieldBuilder AsRichText() + { + Properties().Editor = StringFieldEditor.RichText; + + return this; + } + + public StringFieldBuilder AsDropDown(params string[] values) + { + Properties().AllowedValues = ImmutableList.Create(values); + Properties().Editor = StringFieldEditor.Dropdown; + + return this; + } + + public StringFieldBuilder Pattern(string pattern, string message = null) + { + Properties().Pattern = pattern; + Properties().PatternMessage = message; + + return this; + } + + public StringFieldBuilder Length(int maxLength, int minLength = 0) + { + Properties().MaxLength = maxLength; + Properties().MinLength = minLength; + + return this; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/TagsFieldBuilder.cs b/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/TagsFieldBuilder.cs new file mode 100644 index 000000000..707100951 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Templates/Builders/TagsFieldBuilder.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Entities.Schemas.Commands; + +namespace Squidex.Domain.Apps.Entities.Apps.Templates.Builders +{ + public class TagsFieldBuilder : FieldBuilder + { + public TagsFieldBuilder(CreateSchemaField field) + : base(field) + { + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateBlogCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateBlogCommandMiddleware.cs index 536a4cd42..1dbbee9db 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateBlogCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateBlogCommandMiddleware.cs @@ -6,11 +6,10 @@ // ========================================================================== using System; -using System.Collections.Generic; using System.Threading.Tasks; using Squidex.Domain.Apps.Core.Contents; -using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Domain.Apps.Entities.Apps.Templates.Builders; using Squidex.Domain.Apps.Entities.Contents.Commands; using Squidex.Domain.Apps.Entities.Schemas.Commands; using Squidex.Infrastructure; @@ -23,16 +22,18 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates private const string TemplateName = "Blog"; private const string SlugScript = @" var data = ctx.data; - - data.slug = { iv: slugify(data.title.iv) }; + + if (data.title && data.title.iv) { + data.slug = { iv: slugify(data.title.iv) }; + } replace(data);"; - public Task HandleAsync(CommandContext context, Func next) + public async Task HandleAsync(CommandContext context, Func next) { if (context.IsCompleted && context.Command is CreateApp createApp && IsRightTemplate(createApp)) { - var appId = new NamedId(createApp.AppId, createApp.Name); + var appId = NamedId.Of(createApp.AppId, createApp.Name); var publish = new Func(command => { @@ -44,13 +45,13 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates return context.CommandBus.PublishAsync(command); }); - return Task.WhenAll( + await Task.WhenAll( CreatePagesAsync(publish), CreatePostsAsync(publish), CreateClientAsync(publish, appId.Id)); } - return next(); + await next(); } private static bool IsRightTemplate(CreateApp createApp) @@ -63,7 +64,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates await publish(new AttachClient { Id = "sample-client", AppId = appId }); } - private async Task CreatePostsAsync(Func publish) + private static async Task CreatePostsAsync(Func publish) { var postsId = await CreatePostsSchemaAsync(publish); @@ -78,11 +79,11 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates .AddField("text", new ContentFieldData() .AddValue("iv", "Just created a blog with Squidex. I love it!")), - Publish = true, + Publish = true }); } - private async Task CreatePagesAsync(Func publish) + private static async Task CreatePagesAsync(Func publish) { var pagesId = await CreatePagesSchemaAsync(publish); @@ -101,62 +102,29 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates }); } - private async Task> CreatePostsSchemaAsync(Func publish) + private static async Task> CreatePostsSchemaAsync(Func publish) { - var command = new CreateSchema - { - Name = "posts", - Publish = true, - Properties = new SchemaProperties - { - Label = "Posts" - }, - Fields = new List - { - new CreateSchemaField - { - Name = "title", - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.Input, - IsRequired = true, - IsListField = true, - MaxLength = 100, - MinLength = 0, - Label = "Title" - } - }, - new CreateSchemaField - { - Name = "slug", - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.Slug, - IsRequired = false, - IsListField = true, - MaxLength = 100, - MinLength = 0, - Label = "Slug (Autogenerated)" - }, - IsDisabled = true - }, - new CreateSchemaField - { - Name = "text", - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.RichText, - IsRequired = true, - IsListField = false, - Label = "Text" - } - } - } - }; - - await publish(command); - - var schemaId = new NamedId(command.SchemaId, command.Name); + var schema = + SchemaBuilder.Create("Posts") + .AddString("Title", f => f + .Length(100) + .Required() + .ShowInList() + .Hints("The title of the post.")) + .AddString("Text", f => f + .AsRichText() + .Length(100) + .Required() + .Hints("The text of the post.")) + .AddString("Slug", f => f + .Disabled() + .Label("Slug (Autogenerated)") + .Hints("Autogenerated slug that can be used to identity the post.")) + .Build(); + + await publish(schema); + + var schemaId = NamedId.Of(schema.SchemaId, schema.Name); await publish(new ConfigureScripts { @@ -168,61 +136,29 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates return schemaId; } - private async Task> CreatePagesSchemaAsync(Func publish) + private static async Task> CreatePagesSchemaAsync(Func publish) { - var command = new CreateSchema - { - Name = "pages", - Properties = new SchemaProperties - { - Label = "Pages" - }, - Fields = new List - { - new CreateSchemaField - { - Name = "title", - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.Input, - IsRequired = true, - IsListField = true, - MaxLength = 100, - MinLength = 0, - Label = "Title" - } - }, - new CreateSchemaField - { - Name = "slug", - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.Slug, - IsRequired = false, - IsListField = true, - MaxLength = 100, - MinLength = 0, - Label = "Slug (Autogenerated)" - }, - IsDisabled = true - }, - new CreateSchemaField - { - Name = "text", - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.RichText, - IsRequired = true, - IsListField = false, - Label = "Text" - } - } - } - }; - - await publish(command); - - var schemaId = new NamedId(command.SchemaId, command.Name); + var schema = + SchemaBuilder.Create("Pages") + .AddString("Title", f => f + .Length(100) + .Required() + .ShowInList() + .Hints("The title of the page.")) + .AddString("Text", f => f + .AsRichText() + .Length(100) + .Required() + .Hints("The text of the page.")) + .AddString("Slug", f => f + .Disabled() + .Label("Slug (Autogenerated)") + .Hints("Autogenerated slug that can be used to identity the page.")) + .Build(); + + await publish(schema); + + var schemaId = NamedId.Of(schema.SchemaId, schema.Name); await publish(new ConfigureScripts { @@ -234,4 +170,4 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates return schemaId; } } -} +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateIdentityCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateIdentityCommandMiddleware.cs new file mode 100644 index 000000000..9e244906f --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateIdentityCommandMiddleware.cs @@ -0,0 +1,308 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Domain.Apps.Entities.Apps.Templates.Builders; +using Squidex.Domain.Apps.Entities.Schemas.Commands; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; + +namespace Squidex.Domain.Apps.Entities.Apps.Templates +{ + public sealed class CreateIdentityCommandMiddleware : ICommandMiddleware + { + private const string TemplateName = "Identity"; + private const string NormalizeScript = @" + var data = ctx.data; + + if (data.userName && data.userName.iv) { + data.normalizedUserName = { iv: data.userName.iv.toUpperCase() }; + } + + if (data.email && data.email.iv) { + data.normalizedEmail = { iv: data.email.iv.toUpperCase() }; + } + + replace(data);"; + + public async Task HandleAsync(CommandContext context, Func next) + { + if (context.IsCompleted && context.Command is CreateApp createApp && IsRightTemplate(createApp)) + { + var appId = NamedId.Of(createApp.AppId, createApp.Name); + + var publish = new Func(command => + { + if (command is IAppCommand appCommand) + { + appCommand.AppId = appId; + } + + return context.CommandBus.PublishAsync(command); + }); + + await Task.WhenAll( + CreateApiResourcesSchemaAsync(publish), + CreateAuthenticationSchemeSchemaAsync(publish), + CreateClientsSchemaAsync(publish), + CreateIdentityResourcesSchemaAsync(publish), + CreateSettingsSchemaAsync(publish), + CreateUsersSchemaAsync(publish), + CreateClientAsync(publish, appId.Id)); + } + + await next(); + } + + private static bool IsRightTemplate(CreateApp createApp) + { + return string.Equals(createApp.Template, TemplateName, StringComparison.OrdinalIgnoreCase); + } + + private static async Task CreateClientAsync(Func publish, Guid appId) + { + await publish(new AttachClient { Id = "default", AppId = appId }); + } + + private static async Task> CreateAuthenticationSchemeSchemaAsync(Func publish) + { + var schema = + SchemaBuilder.Create("Authentication Schemes") + .AddString("Provider", f => f + .AsDropDown("Facebook", "Google", "Microsoft", "Twitter") + .Required() + .ShowInList() + .Hints("The name and type of the provider.")) + .AddString("Client Id", f => f + .Required() + .ShowInList() + .Hints("The client id that you must configure at the external provider.")) + .AddString("Client Secret", f => f + .Required() + .Hints("The client secret that you must configure at the external provider.")) + .AddTags("Scopes", f => f + .Hints("Additional scopes you want from the provider.")) + .Build(); + + await publish(schema); + + return NamedId.Of(schema.SchemaId, schema.Name); + } + + private static Task CreateClientsSchemaAsync(Func publish) + { + var schema = + SchemaBuilder.Create("Clients") + .AddString("Client Id", f => f + .Required() + .Hints("Unique id of the client.")) + .AddString("Client Name", f => f + .Localizable() + .Hints("Client display name (used for logging and consent screen).")) + .AddString("Client Uri", f => f + .Localizable() + .Hints("URI to further information about client (used on consent screen).")) + .AddAssets("Logo", f => f + .MustBeImage() + .Hints("URI to client logo (used on consent screen).")) + .AddTags("Client Secrets", f => f + .Hints("Client secrets - only relevant for flows that require a secret.")) + .AddTags("Allowed Scopes", f => f + .Hints("Specifies the api scopes that the client is allowed to request.")) + .AddTags("Allowed Grant Types", f => f + .Hints("Specifies the allowed grant types (legal combinations of AuthorizationCode, Implicit, Hybrid, ResourceOwner, ClientCredentials).")) + .AddTags("Redirect Uris", f => f + .Hints("Specifies allowed URIs to return tokens or authorization codes to")) + .AddTags("Post Logout Redirect Uris", f => f + .Hints("Specifies allowed URIs to redirect to after logout.")) + .AddTags("Allowed Cors Origins", f => f + .Hints("Gets or sets the allowed CORS origins for JavaScript clients.")) + .AddBoolean("Require Consent", f => f + .AsToggle() + .Hints("Specifies whether a consent screen is required.")) + .AddBoolean("Allow Offline Access", f => f + .AsToggle() + .Hints("Gets or sets a value indicating whether to allow offline access.")) + .Build(); + + return publish(schema); + } + + private static Task CreateSettingsSchemaAsync(Func publish) + { + var schema = + SchemaBuilder.Create("Settings").Singleton() + .AddString("Site Name", f => f + .Localizable() + .Hints("The name of your website.")) + .AddAssets("Logo", f => f + .MustBeImage() + .Hints("Logo that is rendered in the header.")) + .AddString("Footer Text", f => f + .Localizable() + .Hints("The optional footer text.")) + .AddString("PrivacyPolicyUrl", f => f + .Localizable() + .Hints("The link to your privacy policies.")) + .AddString("LegalUrl", f => f + .Localizable() + .Hints("The link to your legal information.")) + .AddString("Email Confirmation Text", f => f + .AsTextArea() + .Localizable() + .Hints("The text for the confirmation email.")) + .AddString("Email Confirmation Subject", f => f + .AsTextArea() + .Localizable() + .Hints("The subject for the confirmation email.")) + .AddString("Email Password Reset Text", f => f + .AsTextArea() + .Localizable() + .Hints("The text for the password reset email.")) + .AddString("Email Password Reset Subject", f => f + .AsTextArea() + .Localizable() + .Hints("The subject for the password reset email.")) + .AddString("Terms of Service Url", f => f + .Localizable() + .Hints("The link to your tems of service.")) + .AddString("Bootstrap Url", f => f + .Hints("The link to a custom bootstrap theme.")) + .AddString("Styles Url", f => f + .Hints("The link to a stylesheet.")) + .AddString("SMTP From", f => f + .Hints("The SMTP sender address.")) + .AddString("SMTP Server", f => f + .Hints("The smpt server.")) + .AddString("SMTP Username", f => f + .Hints("The username for your SMTP server.")) + .AddString("SMTP Password", f => f + .Hints("The password for your SMTP server.")) + .AddString("Google Analytics Id", f => f + .Hints("The id to your google analytics account.")) + .Build(); + + return publish(schema); + } + + private static async Task CreateUsersSchemaAsync(Func publish) + { + var schema = + SchemaBuilder.Create("Users") + .AddString("Username", f => f + .Required() + .ShowInList() + .Hints("The unique username to login.")) + .AddString("Email", f => f + .Pattern(@"^[a-zA-Z0-9.!#$%&’*+\\/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:.[a-zA-Z0-9-]+)*$", "Must be an email address.") + .Required() + .ShowInList() + .Hints("The unique email to login.")) + .AddString("Phone Number", f => f + .Hints("Phone number of the user.")) + .AddTags("Roles", f => f + .Hints("The roles of the user.")) + .AddJson("Claims", f => f + .Hints("The claims of the user.")) + .AddBoolean("Email Confirmed", f => f + .AsToggle() + .Hints("Indicates if the email is confirmed.")) + .AddBoolean("Phone Number Confirmed", f => f + .AsToggle() + .Hints("Indicates if the phone number is confirmed.")) + .AddBoolean("LockoutEnabled", f => f + .AsToggle() + .Hints("Toggle on to lock out the user.")) + .AddDateTime("Lockout End Date Utc", f => f + .AsDateTime() + .Disabled() + .Hints("Indicates when the lockout ends.")) + .AddTags("Login Keys", f => f + .Disabled() + .Hints("Login information for querying.")) + .AddJson("Logins", f => f + .Disabled() + .Hints("Login information.")) + .AddJson("Tokens", f => f + .Disabled() + .Hints("Login tokens.")) + .AddNumber("Access Failed Count", f => f + .Disabled() + .Hints("The number of failed login attempts.")) + .AddString("Password Hash", f => f + .Disabled() + .Hints("The hashed password.")) + .AddString("Normalized Email", f => f + .Disabled() + .Hints("The normalized email for querying.")) + .AddString("Normalized Username", f => f + .Disabled() + .Hints("The normalized user name for querying.")) + .AddString("Security Stamp", f => f + .Disabled() + .Hints("Internal security stamp")) + .Build(); + + await publish(schema); + + var schemaId = NamedId.Of(schema.SchemaId, schema.Name); + + await publish(new ConfigureScripts + { + SchemaId = schemaId.Id, + ScriptCreate = NormalizeScript, + ScriptUpdate = NormalizeScript + }); + } + + private static Task CreateApiResourcesSchemaAsync(Func publish) + { + var schema = + SchemaBuilder.Create("API Resources") + .AddString("Name", f => f + .Required() + .ShowInList() + .Hints("The unique name of the API.")) + .AddString("Display Name", f => f + .Localizable() + .Hints("The display name of the API.")) + .AddString("Description", f => f + .Localizable() + .Hints("The description name of the API.")) + .AddTags("User Claims", f => f + .Hints("List of accociated user claims that should be included when this resource is requested.")) + .Build(); + + return publish(schema); + } + + private static Task CreateIdentityResourcesSchemaAsync(Func publish) + { + var schema = + SchemaBuilder.Create("Identity Resources") + .AddString("Name", f => f + .Required() + .ShowInList() + .Hints("The unique name of the identity information.")) + .AddString("Display Name", f => f + .Localizable() + .Hints("The display name of the identity information.")) + .AddString("Description", f => f + .Localizable() + .Hints("The description name of the identity information.")) + .AddTags("User Claims", f => f + .Hints("List of accociated user claims that should be included when this resource is requested.")) + .AddBoolean("Required", f => f + .Hints("Specifies whether the user can de-select the scope on the consent screen.")) + .Build(); + + return publish(schema); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateProfileCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateProfileCommandMiddleware.cs index 936b0a055..51c3e34ed 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateProfileCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateProfileCommandMiddleware.cs @@ -6,14 +6,11 @@ // ========================================================================== using System; -using System.Collections.Generic; -using System.Collections.Immutable; using System.Threading.Tasks; using Squidex.Domain.Apps.Core.Contents; -using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Domain.Apps.Entities.Apps.Templates.Builders; using Squidex.Domain.Apps.Entities.Contents.Commands; -using Squidex.Domain.Apps.Entities.Schemas.Commands; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; @@ -23,11 +20,11 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates { private const string TemplateName = "Profile"; - public Task HandleAsync(CommandContext context, Func next) + public async Task HandleAsync(CommandContext context, Func next) { if (context.IsCompleted && context.Command is CreateApp createApp && IsRightTemplate(createApp)) { - var appId = new NamedId(createApp.AppId, createApp.Name); + var appId = NamedId.Of(createApp.AppId, createApp.Name); var publish = new Func(command => { @@ -39,17 +36,17 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates return context.CommandBus.PublishAsync(command); }); - return Task.WhenAll( + await Task.WhenAll( CreateBasicsAsync(publish), - CreateProjectsSchemaAsync(publish), - CreateExperienceSchemaAsync(publish), - CreateSkillsSchemaAsync(publish), CreateEducationSchemaAsync(publish), + CreateExperienceSchemaAsync(publish), + CreateProjectsSchemaAsync(publish), CreatePublicationsSchemaAsync(publish), + CreateSkillsSchemaAsync(publish), CreateClientAsync(publish, appId.Id)); } - return next(); + await next(); } private static bool IsRightTemplate(CreateApp createApp) @@ -62,7 +59,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates await publish(new AttachClient { Id = "sample-client", AppId = appId }); } - private async Task CreateBasicsAsync(Func publish) + private static async Task CreateBasicsAsync(Func publish) { var postsId = await CreateBasicsSchemaAsync(publish); @@ -80,524 +77,176 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates .AddField("profession", new ContentFieldData() .AddValue("iv", "Software Developer")), - Publish = true, + Publish = true }); } - private async Task> CreateBasicsSchemaAsync(Func publish) + private static async Task> CreateBasicsSchemaAsync(Func publish) { - var command = new CreateSchema - { - Name = "basics", - Properties = new SchemaProperties - { - Label = "Basics" - }, - Fields = new List - { - new CreateSchemaField - { - Name = "firstName", - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.Input, - IsRequired = true, - IsListField = true, - Label = "First Name", - Hints = "Your first name" - } - }, - new CreateSchemaField - { - Name = "lastName", - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.Input, - IsRequired = true, - IsListField = true, - Label = "Last Name", - Hints = "Your last name" - } - }, - new CreateSchemaField - { - Name = "profession", - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.TextArea, - IsRequired = true, - IsListField = false, - Label = "Profession", - Hints = "Define your profession" - } - }, - new CreateSchemaField - { - Name = "image", - Properties = new AssetsFieldProperties - { - IsRequired = false, - IsListField = false, - MustBeImage = true, - Label = "Image", - Hints = "Your image" - } - }, - new CreateSchemaField - { - Name = "summary", - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.TextArea, - IsRequired = false, - IsListField = false, - Label = "Summary", - Hints = "Write a short summary about yourself" - } - }, - new CreateSchemaField - { - Name = "githubLink", - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.Input, - IsRequired = false, - IsListField = false, - Label = "Github", - Hints = "An optional link to your Github account" - } - }, - new CreateSchemaField - { - Name = "blogLink", - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.Input, - IsRequired = false, - IsListField = false, - Label = "Blog", - Hints = "An optional link to your blog" - } - }, - new CreateSchemaField - { - Name = "twitterLink", - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.Input, - IsRequired = false, - IsListField = false, - Label = "Twitter", - Hints = "An optional link to your twitter account" - } - }, - new CreateSchemaField - { - Name = "linkedInLink", - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.Input, - IsRequired = false, - IsListField = false, - Label = "LinkedIn", - Hints = "An optional link to your LinkedIn account" - } - }, - new CreateSchemaField - { - Name = "emailAddress", - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.Input, - IsRequired = false, - IsListField = false, - Label = "Email Address", - Hints = "An optional email address to contact you" - } - }, - new CreateSchemaField - { - Name = "legalTerms", - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.TextArea, - IsRequired = false, - IsListField = false, - Label = "Legal terms", - Hints = "The terms to fulfill legal requirements" - } - } - }, - Publish = true - }; + var command = + SchemaBuilder.Create("basics") + .AddString("First Name", f => f + .Required() + .ShowInList() + .Hints("Your first name.")) + .AddString("Last Name", f => f + .Required() + .ShowInList() + .Hints("Your last name.")) + .AddAssets("Image", f => f + .MustBeImage() + .Hints("Your profile image.")) + .AddString("Profession", f => f + .AsTextArea() + .Required() + .Hints("Describe your profession.")) + .AddString("Summary", f => f + .AsTextArea() + .Hints("Write a short summary about yourself.")) + .AddString("Legal Terms", f => f + .AsTextArea() + .Hints("The terms to fulfill legal requirements.")) + .AddString("Github Link", f => f + .Hints("An optional link to your Github account.")) + .AddString("Blog Link", f => f + .Hints("An optional link to your Blog.")) + .AddString("Twitter Link", f => f + .Hints("An optional link to your Twitter account.")) + .AddString("LinkedIn Link", f => f + .Hints("An optional link to your LinkedIn account.")) + .AddString("Email Address", f => f + .Hints("An optional email address to contact you.")) + .Build(); await publish(command); - return new NamedId(command.SchemaId, command.Name); + return NamedId.Of(command.SchemaId, command.Name); } - private async Task> CreateProjectsSchemaAsync(Func publish) + private static async Task> CreateProjectsSchemaAsync(Func publish) { - var command = new CreateSchema - { - Name = "projects", - Properties = new SchemaProperties - { - Label = "Projects" - }, - Fields = new List - { - new CreateSchemaField - { - Name = "name", - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.Input, - IsRequired = true, - IsListField = true, - Label = "Name", - Hints = "The name of the projection" - } - }, - new CreateSchemaField - { - Name = "description", - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.TextArea, - IsRequired = true, - IsListField = false, - Label = "Description", - Hints = "Describe your project" - } - }, - new CreateSchemaField - { - Name = "image", - Properties = new AssetsFieldProperties - { - IsRequired = true, - IsListField = false, - MustBeImage = true, - Label = "Image", - Hints = "An image or screenshot for your project" - } - }, - new CreateSchemaField - { - Name = "label", - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.Input, - IsRequired = false, - IsListField = false, - Label = "Label", - Hints = "An optional label to categorize your project, e.g. 'Open Source'" - } - }, - new CreateSchemaField - { - Name = "link", - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.Input, - IsRequired = false, - IsListField = false, - Label = "link", - Hints = "The logo of the company or organization you worked for" - } - }, - new CreateSchemaField - { - Name = "year", - Properties = new NumberFieldProperties - { - IsRequired = false, - IsListField = false, - Label = "Year", - Hints = "The year, when you realized the project, used for sorting only" - } - } - }, - Publish = true - }; - - await publish(command); - - return new NamedId(command.SchemaId, command.Name); + var schema = + SchemaBuilder.Create("projects") + .AddString("Name", f => f + .Required() + .ShowInList() + .Hints("The name of your project.")) + .AddString("Description", f => f + .AsTextArea() + .Required() + .Hints("Describe your project.")) + .AddAssets("Image", f => f + .MustBeImage() + .Required() + .Hints("An image or screenshot for your project.")) + .AddString("Label", f => f + .AsTextArea() + .Hints("An optional label to categorize your project, e.g. 'Open Source'.")) + .AddString("Link", f => f + .Hints("An optional link to your project.")) + .AddNumber("Year", f => f + .Hints("The year, when you realized the project, used for sorting only.")) + .Build(); + + await publish(schema); + + return NamedId.Of(schema.SchemaId, schema.Name); } - private async Task> CreateExperienceSchemaAsync(Func publish) + private static async Task> CreateExperienceSchemaAsync(Func publish) { - var command = new CreateSchema - { - Name = "experience", - Properties = new SchemaProperties - { - Label = "Experience" - }, - Fields = new List - { - new CreateSchemaField - { - Name = "position", - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.Input, - IsRequired = true, - IsListField = true, - Label = "Position", - Hints = "Your position in this job" - } - }, - new CreateSchemaField - { - Name = "company", - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.Input, - IsRequired = true, - IsListField = true, - Label = "Company", - Hints = "The company or organization you worked for" - } - }, - new CreateSchemaField - { - Name = "logo", - Properties = new AssetsFieldProperties - { - IsRequired = false, - IsListField = false, - MustBeImage = true, - Label = "Logo", - Hints = "The logo of the company or organization you worked for" - } - }, - new CreateSchemaField - { - Name = "from", - Properties = new DateTimeFieldProperties - { - Editor = DateTimeFieldEditor.Date, - IsRequired = true, - IsListField = false, - Label = "Start Date", - Hints = "The start date" - } - }, - new CreateSchemaField - { - Name = "to", - Properties = new DateTimeFieldProperties - { - Editor = DateTimeFieldEditor.Date, - IsRequired = false, - IsListField = false, - Label = "End Date", - Hints = "The end date, keep empty if you still work there" - } - } - }, - Publish = true - }; - - await publish(command); - - return new NamedId(command.SchemaId, command.Name); + var schema = + SchemaBuilder.Create("experience") + .AddString("Position", f => f + .Required() + .ShowInList() + .Hints("Your position in this job.")) + .AddString("Company", f => f + .Required() + .ShowInList() + .Hints("The company or organization you worked for.")) + .AddAssets("Logo", f => f + .MustBeImage() + .Hints("The logo of the company or organization you worked for.")) + .AddDateTime("From", f => f + .Required() + .Hints("The start date.")) + .AddDateTime("To", f => f + .Hints("The end date, keep empty if you still work there.")) + .Build(); + + await publish(schema); + + return NamedId.Of(schema.SchemaId, schema.Name); } - private async Task> CreateEducationSchemaAsync(Func publish) + private static async Task> CreateEducationSchemaAsync(Func publish) { - var command = new CreateSchema - { - Name = "education", - Properties = new SchemaProperties - { - Label = "Education" - }, - Fields = new List - { - new CreateSchemaField - { - Name = "degree", - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.Input, - IsRequired = true, - IsListField = true, - Label = "Degree", - Hints = "The degree you got or achieved" - } - }, - new CreateSchemaField - { - Name = "school", - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.Input, - IsRequired = true, - IsListField = true, - Label = "School", - Hints = "The school or university" - } - }, - new CreateSchemaField - { - Name = "logo", - Properties = new AssetsFieldProperties - { - IsRequired = false, - IsListField = false, - MustBeImage = true, - Label = "Logo", - Hints = "The logo of the school" - } - }, - new CreateSchemaField - { - Name = "from", - Properties = new DateTimeFieldProperties - { - Editor = DateTimeFieldEditor.Date, - IsRequired = true, - IsListField = false, - Label = "Start Date", - Hints = "The start date" - } - }, - new CreateSchemaField - { - Name = "to", - Properties = new DateTimeFieldProperties - { - Editor = DateTimeFieldEditor.Date, - IsRequired = false, - IsListField = false, - Label = "End Date", - Hints = "The end date, keep empty if you still study there" - } - } - }, - Publish = true - }; - - await publish(command); - - return new NamedId(command.SchemaId, command.Name); + var schema = + SchemaBuilder.Create("Experience") + .AddString("Degree", f => f + .Required() + .ShowInList() + .Hints("The degree you got or achieved.")) + .AddString("School", f => f + .Required() + .ShowInList() + .Hints("The school or university.")) + .AddAssets("Logo", f => f + .MustBeImage() + .Hints("The logo of the school or university.")) + .AddDateTime("From", f => f + .Required() + .Hints("The start date.")) + .AddDateTime("To", f => f + .Hints("The end date, keep empty if you still study there.")) + .Build(); + + await publish(schema); + + return NamedId.Of(schema.SchemaId, schema.Name); } - private async Task> CreatePublicationsSchemaAsync(Func publish) + private static async Task> CreatePublicationsSchemaAsync(Func publish) { - var command = new CreateSchema - { - Name = "publications", - Properties = new SchemaProperties - { - Label = "Publications" - }, - Fields = new List - { - new CreateSchemaField - { - Name = "name", - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.Input, - IsRequired = true, - IsListField = true, - Label = "Name", - Hints = "The name or title of your publication" - } - }, - new CreateSchemaField - { - Name = "description", - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.TextArea, - IsRequired = false, - IsListField = false, - Label = "Description", - Hints = "Describe the content of your publication" - } - }, - new CreateSchemaField - { - Name = "cover", - Properties = new AssetsFieldProperties - { - IsRequired = true, - IsListField = false, - MustBeImage = true, - Label = "Cover", - Hints = "The cover of your publication" - } - }, - new CreateSchemaField - { - Name = "link", - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.Input, - IsRequired = false, - IsListField = false, - Label = "Link", - Hints = "An optional link to your publication" - } - } - }, - Publish = true - }; + var command = + SchemaBuilder.Create("Publications") + .AddString("Name", f => f + .Required() + .ShowInList() + .Hints("The name or title of your publication.")) + .AddAssets("Cover", f => f + .MustBeImage() + .Hints("The cover of your publication.")) + .AddString("Description", f => f + .Hints("Describe the content of your publication.")) + .AddString("Link", f => f + .Hints("Optional link to your publication.")) + .Build(); await publish(command); - return new NamedId(command.SchemaId, command.Name); + return NamedId.Of(command.SchemaId, command.Name); } - private async Task> CreateSkillsSchemaAsync(Func publish) + private static async Task> CreateSkillsSchemaAsync(Func publish) { - var command = new CreateSchema - { - Name = "skills", - Properties = new SchemaProperties - { - Label = "Skills" - }, - Fields = new List - { - new CreateSchemaField - { - Name = "name", - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.Input, - IsRequired = true, - IsListField = true, - Label = "Name", - Hints = "The name for your skill" - } - }, - new CreateSchemaField - { - Name = "experience", - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.Dropdown, - IsRequired = true, - IsListField = true, - AllowedValues = ImmutableList.Create("Beginner", "Advanced", "Professional", "Expert"), - Label = "Experience", - Hints = "The level of experience" - } - } - }, - Publish = true - }; + var command = + SchemaBuilder.Create("Skills") + .AddString("Name", f => f + .Required() + .ShowInList() + .Hints("The name of the skill.")) + .AddString("Experience", f => f + .AsDropDown("Beginner", "Advanced", "Professional", "Expert") + .Required() + .ShowInList() + .Hints("The level of experience.")) + .Build(); await publish(command); - return new NamedId(command.SchemaId, command.Name); + return NamedId.Of(command.SchemaId, command.Name); } } } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs index 734ddb57c..5b605ac8b 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs @@ -6,53 +6,72 @@ // ========================================================================== using System; +using System.Collections.Generic; using System.Threading.Tasks; +using Orleans; using Squidex.Domain.Apps.Entities.Assets.Commands; +using Squidex.Domain.Apps.Entities.Tags; using Squidex.Infrastructure; using Squidex.Infrastructure.Assets; using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.States; namespace Squidex.Domain.Apps.Entities.Assets { - public sealed class AssetCommandMiddleware : GrainCommandMiddleware + public sealed class AssetCommandMiddleware : GrainCommandMiddleware { private readonly IAssetStore assetStore; private readonly IAssetThumbnailGenerator assetThumbnailGenerator; + private readonly IEnumerable> tagGenerators; public AssetCommandMiddleware( - IStateFactory stateFactory, + IGrainFactory grainFactory, IAssetStore assetStore, - IAssetThumbnailGenerator assetThumbnailGenerator) - : base(stateFactory) + IAssetThumbnailGenerator assetThumbnailGenerator, + IEnumerable> tagGenerators) + : base(grainFactory) { Guard.NotNull(assetStore, nameof(assetStore)); Guard.NotNull(assetThumbnailGenerator, nameof(assetThumbnailGenerator)); + Guard.NotNull(tagGenerators, nameof(tagGenerators)); this.assetStore = assetStore; this.assetThumbnailGenerator = assetThumbnailGenerator; + + this.tagGenerators = tagGenerators; } - public async override Task HandleAsync(CommandContext context, Func next) + public override async Task HandleAsync(CommandContext context, Func next) { switch (context.Command) { case CreateAsset createAsset: { + if (createAsset.Tags == null) + { + createAsset.Tags = new HashSet(); + } + createAsset.ImageInfo = await assetThumbnailGenerator.GetImageInfoAsync(createAsset.File.OpenRead()); - await assetStore.UploadTemporaryAsync(context.ContextId.ToString(), createAsset.File.OpenRead()); + foreach (var tagGenerator in tagGenerators) + { + tagGenerator.GenerateTags(createAsset, createAsset.Tags); + } + + var originalTags = new HashSet(createAsset.Tags); + + await assetStore.UploadAsync(context.ContextId.ToString(), createAsset.File.OpenRead()); try { var result = await ExecuteCommandAsync(createAsset) as AssetSavedResult; - context.Complete(EntityCreatedResult.Create(createAsset.AssetId, result.Version)); + context.Complete(new AssetCreatedResult(createAsset.AssetId, originalTags, result.Version)); - await assetStore.CopyTemporaryAsync(context.ContextId.ToString(), createAsset.AssetId.ToString(), result.FileVersion, null); + await assetStore.CopyAsync(context.ContextId.ToString(), createAsset.AssetId.ToString(), result.FileVersion, null); } finally { - await assetStore.DeleteTemporaryAsync(context.ContextId.ToString()); + await assetStore.DeleteAsync(context.ContextId.ToString()); } break; @@ -62,18 +81,18 @@ namespace Squidex.Domain.Apps.Entities.Assets { updateAsset.ImageInfo = await assetThumbnailGenerator.GetImageInfoAsync(updateAsset.File.OpenRead()); - await assetStore.UploadTemporaryAsync(context.ContextId.ToString(), updateAsset.File.OpenRead()); + await assetStore.UploadAsync(context.ContextId.ToString(), updateAsset.File.OpenRead()); try { var result = await ExecuteCommandAsync(updateAsset) as AssetSavedResult; context.Complete(result); - await assetStore.CopyTemporaryAsync(context.ContextId.ToString(), updateAsset.AssetId.ToString(), result.FileVersion, null); + await assetStore.CopyAsync(context.ContextId.ToString(), updateAsset.AssetId.ToString(), result.FileVersion, null); } finally { - await assetStore.DeleteTemporaryAsync(context.ContextId.ToString()); + await assetStore.DeleteAsync(context.ContextId.ToString()); } break; diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetCreatedResult.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetCreatedResult.cs new file mode 100644 index 000000000..8abb01c95 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/AssetCreatedResult.cs @@ -0,0 +1,28 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using Squidex.Infrastructure.Commands; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public sealed class AssetCreatedResult : EntitySavedResult + { + public Guid Id { get; } + + public HashSet Tags { get; } + + public AssetCreatedResult(Guid id, HashSet tags, long version) + : base(version) + { + Id = id; + + Tags = tags; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs index 19858fbf1..8173494e3 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs @@ -6,7 +6,9 @@ // ========================================================================== using System; +using System.Collections.Generic; using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.Tags; using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Domain.Apps.Entities.Assets.Guards; using Squidex.Domain.Apps.Entities.Assets.State; @@ -15,59 +17,88 @@ using Squidex.Domain.Apps.Events.Assets; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.States; namespace Squidex.Domain.Apps.Entities.Assets { - public class AssetGrain : DomainObjectGrain + public sealed class AssetGrain : SquidexDomainObjectGrainLogSnapshots, IAssetGrain { - public AssetGrain(IStore store) - : base(store) + private readonly ITagService tagService; + + public AssetGrain(IStore store, ITagService tagService, ISemanticLog log) + : base(store, log) { + Guard.NotNull(tagService, nameof(tagService)); + + this.tagService = tagService; } - public override Task ExecuteAsync(IAggregateCommand command) + protected override Task ExecuteAsync(IAggregateCommand command) { + VerifyNotDeleted(); + switch (command) { case CreateAsset createRule: - return CreateReturnAsync(createRule, c => + return CreateReturnAsync(createRule, async c => { GuardAsset.CanCreate(c); + c.Tags = await NormalizeTagsAsync(c.AppId.Id, c.Tags); + Create(c); - return new AssetSavedResult(NewVersion, Snapshot.FileVersion); + return new AssetSavedResult(Version, Snapshot.FileVersion); }); case UpdateAsset updateRule: - return UpdateReturnAsync(updateRule, c => + return UpdateAsync(updateRule, c => { GuardAsset.CanUpdate(c); Update(c); - return new AssetSavedResult(NewVersion, Snapshot.FileVersion); + return new AssetSavedResult(Version, Snapshot.FileVersion); }); - case RenameAsset renameAsset: - return UpdateAsync(renameAsset, c => + case TagAsset tagAsset: + return UpdateAsync(tagAsset, async c => { - GuardAsset.CanRename(c, Snapshot.FileName); + GuardAsset.CanTag(c); - Rename(c); + c.Tags = await NormalizeTagsAsync(Snapshot.AppId.Id, c.Tags); + + Tag(c); }); case DeleteAsset deleteAsset: - return UpdateAsync(deleteAsset, c => + return UpdateAsync(deleteAsset, async c => { GuardAsset.CanDelete(c); + await tagService.NormalizeTagsAsync(Snapshot.AppId.Id, TagGroups.Assets, null, Snapshot.Tags); + Delete(c); }); + case RenameAsset renameAsset: + return UpdateAsync(renameAsset, c => + { + GuardAsset.CanRename(c, Snapshot.FileName); + + Rename(c); + }); default: throw new NotSupportedException(); } } + private async Task> NormalizeTagsAsync(Guid appId, HashSet tags) + { + var normalized = await tagService.NormalizeTagsAsync(appId, TagGroups.Assets, tags, Snapshot.Tags); + + return new HashSet(normalized.Values); + } + public void Create(CreateAsset command) { var @event = SimpleMapper.Map(command, new AssetCreated @@ -103,18 +134,19 @@ namespace Squidex.Domain.Apps.Entities.Assets public void Delete(DeleteAsset command) { - VerifyNotDeleted(); - RaiseEvent(SimpleMapper.Map(command, new AssetDeleted { DeletedSize = Snapshot.TotalSize })); } public void Rename(RenameAsset command) { - VerifyNotDeleted(); - RaiseEvent(SimpleMapper.Map(command, new AssetRenamed())); } + public void Tag(TagAsset command) + { + RaiseEvent(SimpleMapper.Map(command, new AssetTagged())); + } + private void RaiseEvent(AppEvent @event) { if (@event.AppId == null) @@ -133,9 +165,14 @@ namespace Squidex.Domain.Apps.Entities.Assets } } - public override void ApplyEvent(Envelope @event) + protected override AssetState OnEvent(Envelope @event) + { + return Snapshot.Apply(@event); + } + + public Task> GetStateAsync(long version = EtagVersion.Any) { - ApplySnapshot(Snapshot.Apply(@event)); + return J.AsTask(GetSnapshot(version)); } } } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs new file mode 100644 index 000000000..e706b3dcd --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs @@ -0,0 +1,146 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.OData; +using Squidex.Domain.Apps.Core.Tags; +using Squidex.Domain.Apps.Entities.Assets.Edm; +using Squidex.Domain.Apps.Entities.Assets.Queries; +using Squidex.Domain.Apps.Entities.Assets.Repositories; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Queries; +using Squidex.Infrastructure.Queries.OData; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public sealed class AssetQueryService : IAssetQueryService + { + private const int MaxResults = 200; + private readonly ITagService tagService; + private readonly IAssetRepository assetRepository; + + public AssetQueryService(ITagService tagService, IAssetRepository assetRepository) + { + Guard.NotNull(tagService, nameof(tagService)); + Guard.NotNull(assetRepository, nameof(assetRepository)); + + this.tagService = tagService; + + this.assetRepository = assetRepository; + } + + public async Task FindAssetAsync(QueryContext context, Guid id) + { + Guard.NotNull(context, nameof(context)); + + var asset = await assetRepository.FindAssetAsync(id); + + if (asset != null) + { + await DenormalizeTagsAsync(context.App.Id, Enumerable.Repeat(asset, 1)); + } + + return asset; + } + + public async Task> QueryAsync(QueryContext context, Q query) + { + Guard.NotNull(context, nameof(context)); + Guard.NotNull(query, nameof(query)); + + IResultList assets; + + if (query.Ids != null) + { + assets = await assetRepository.QueryAsync(context.App.Id, new HashSet(query.Ids)); + assets = Sort(assets, query.Ids); + } + else + { + var parsedQuery = ParseQuery(context, query.ODataQuery); + + assets = await assetRepository.QueryAsync(context.App.Id, parsedQuery); + } + + await DenormalizeTagsAsync(context.App.Id, assets); + + return assets; + } + + private static IResultList Sort(IResultList assets, IReadOnlyList ids) + { + var sorted = ids.Select(id => assets.FirstOrDefault(x => x.Id == id)).Where(x => x != null); + + return ResultList.Create(assets.Total, sorted); + } + + private Query ParseQuery(QueryContext context, string query) + { + try + { + var result = EdmAssetModel.Edm.ParseQuery(query).ToQuery(); + + if (result.Filter != null) + { + result.Filter = FilterTagTransformer.Transform(result.Filter, context.App.Id, tagService); + } + + if (result.Sort.Count == 0) + { + result.Sort.Add(new SortNode(new List { "lastModified" }, SortOrder.Descending)); + } + + if (result.Take > MaxResults) + { + result.Take = MaxResults; + } + + return result; + } + catch (NotSupportedException) + { + throw new ValidationException("OData operation is not supported."); + } + catch (ODataException ex) + { + throw new ValidationException($"Failed to parse query: {ex.Message}", ex); + } + } + + private async Task DenormalizeTagsAsync(Guid appId, IEnumerable assets) + { + var tags = new HashSet(assets.Where(x => x.Tags != null).SelectMany(x => x.Tags).Distinct()); + + var tagsById = await tagService.DenormalizeTagsAsync(appId, TagGroups.Assets, tags); + + foreach (var asset in assets) + { + if (asset.Tags?.Count > 0) + { + var tagNames = asset.Tags.ToList(); + + asset.Tags.Clear(); + + foreach (var id in tagNames) + { + if (tagsById.TryGetValue(id, out var name)) + { + asset.Tags.Add(name); + } + } + } + else + { + asset.Tags?.Clear(); + } + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs b/src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs new file mode 100644 index 000000000..d60e3070c --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs @@ -0,0 +1,134 @@ +// ========================================================================== +// 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 Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.Tags; +using Squidex.Domain.Apps.Entities.Assets.Repositories; +using Squidex.Domain.Apps.Entities.Assets.State; +using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Domain.Apps.Events.Assets; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Assets; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.States; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public sealed class BackupAssets : BackupHandlerWithStore + { + private const string TagsFile = "AssetTags.json"; + private readonly HashSet assetIds = new HashSet(); + private readonly IAssetStore assetStore; + private readonly IAssetRepository assetRepository; + private readonly ITagService tagService; + + public override string Name { get; } = "Assets"; + + public BackupAssets(IStore store, + IAssetStore assetStore, + IAssetRepository assetRepository, + ITagService tagService) + : base(store) + { + Guard.NotNull(assetStore, nameof(assetStore)); + Guard.NotNull(assetRepository, nameof(assetRepository)); + Guard.NotNull(tagService, nameof(tagService)); + + this.assetStore = assetStore; + this.assetRepository = assetRepository; + this.tagService = tagService; + } + + public override Task BackupAsync(Guid appId, BackupWriter writer) + { + return BackupTagsAsync(appId, writer); + } + + public override Task BackupEventAsync(Envelope @event, Guid appId, BackupWriter writer) + { + switch (@event.Payload) + { + case AssetCreated assetCreated: + return WriteAssetAsync(assetCreated.AssetId, assetCreated.FileVersion, writer); + case AssetUpdated assetUpdated: + return WriteAssetAsync(assetUpdated.AssetId, assetUpdated.FileVersion, writer); + } + + return TaskHelper.Done; + } + + public override async Task RestoreEventAsync(Envelope @event, Guid appId, BackupReader reader, RefToken actor) + { + switch (@event.Payload) + { + case AssetCreated assetCreated: + await ReadAssetAsync(assetCreated.AssetId, assetCreated.FileVersion, reader); + break; + case AssetUpdated assetUpdated: + await ReadAssetAsync(assetUpdated.AssetId, assetUpdated.FileVersion, reader); + break; + } + + return true; + } + + public override async Task RestoreAsync(Guid appId, BackupReader reader) + { + await RestoreTagsAsync(appId, reader); + + await RebuildManyAsync(assetIds, id => RebuildAsync(id, (e, s) => s.Apply(e))); + } + + private async Task RestoreTagsAsync(Guid appId, BackupReader reader) + { + var tags = await reader.ReadJsonAttachmentAsync(TagsFile); + + await tagService.RebuildTagsAsync(appId, TagGroups.Assets, tags.ToObject()); + } + + private async Task BackupTagsAsync(Guid appId, BackupWriter writer) + { + var tags = await tagService.GetExportableTagsAsync(appId, TagGroups.Assets); + + await writer.WriteJsonAsync(TagsFile, JObject.FromObject(tags)); + } + + private Task WriteAssetAsync(Guid assetId, long fileVersion, BackupWriter writer) + { + return writer.WriteBlobAsync(GetName(assetId, fileVersion), stream => + { + return assetStore.DownloadAsync(assetId.ToString(), fileVersion, null, stream); + }); + } + + private Task ReadAssetAsync(Guid assetId, long fileVersion, BackupReader reader) + { + assetIds.Add(assetId); + + return reader.ReadBlobAsync(GetName(reader.OldGuid(assetId), fileVersion), async stream => + { + try + { + await assetStore.UploadAsync(assetId.ToString(), fileVersion, null, stream); + } + catch (AssetAlreadyExistsException) + { + return; + } + }); + } + + private static string GetName(Guid assetId, long fileVersion) + { + return $"{assetId}_{fileVersion}.asset"; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs b/src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs index f421d9ed8..829cf0ce5 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs @@ -6,6 +6,7 @@ // ========================================================================== using System; +using System.Collections.Generic; using Squidex.Infrastructure; using Squidex.Infrastructure.Assets; @@ -19,6 +20,8 @@ namespace Squidex.Domain.Apps.Entities.Assets.Commands public ImageInfo ImageInfo { get; set; } + public HashSet Tags { get; set; } + public CreateAsset() { AssetId = Guid.NewGuid(); diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Commands/TagAsset.cs b/src/Squidex.Domain.Apps.Entities/Assets/Commands/TagAsset.cs new file mode 100644 index 000000000..7ca591f53 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/Commands/TagAsset.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; + +namespace Squidex.Domain.Apps.Entities.Assets.Commands +{ + public sealed class TagAsset : AssetCommand + { + public HashSet Tags { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Edm/EdmAssetModel.cs b/src/Squidex.Domain.Apps.Entities/Assets/Edm/EdmAssetModel.cs index ffb58c065..b6b77f59a 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/Edm/EdmAssetModel.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/Edm/EdmAssetModel.cs @@ -30,6 +30,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Edm entityType.AddStructuralProperty(nameof(IAssetEntity.MimeType).ToCamelCase(), EdmPrimitiveTypeKind.String); entityType.AddStructuralProperty(nameof(IAssetEntity.PixelHeight).ToCamelCase(), EdmPrimitiveTypeKind.Int32); entityType.AddStructuralProperty(nameof(IAssetEntity.PixelWidth).ToCamelCase(), EdmPrimitiveTypeKind.Int32); + entityType.AddStructuralProperty(nameof(IAssetEntity.Tags).ToCamelCase(), EdmPrimitiveTypeKind.String); var container = new EdmEntityContainer("Squidex", "Container"); diff --git a/src/Squidex.Domain.Apps.Entities/Assets/FileTypeTagGenerator.cs b/src/Squidex.Domain.Apps.Entities/Assets/FileTypeTagGenerator.cs new file mode 100644 index 000000000..88df12a99 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/FileTypeTagGenerator.cs @@ -0,0 +1,27 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using Squidex.Domain.Apps.Entities.Assets.Commands; +using Squidex.Domain.Apps.Entities.Tags; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public sealed class FileTypeTagGenerator : ITagGenerator + { + public void GenerateTags(CreateAsset source, HashSet tags) + { + var extension = source.File?.FileName?.FileType(); + + if (!string.IsNullOrWhiteSpace(extension)) + { + tags.Add($"type/{extension.ToLowerInvariant()}"); + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Guards/GuardAsset.cs b/src/Squidex.Domain.Apps.Entities/Assets/Guards/GuardAsset.cs index 757959a6a..f9970aa1f 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/Guards/GuardAsset.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/Guards/GuardAsset.cs @@ -16,16 +16,16 @@ namespace Squidex.Domain.Apps.Entities.Assets.Guards { Guard.NotNull(command, nameof(command)); - Validate.It(() => "Cannot rename asset.", error => + Validate.It(() => "Cannot rename asset.", e => { if (string.IsNullOrWhiteSpace(command.FileName)) { - error(new ValidationError("Name is required.", nameof(command.FileName))); + e("Name is required.", nameof(command.FileName)); } if (string.Equals(command.FileName, oldName)) { - error(new ValidationError("Name is equal to old name.", nameof(command.FileName))); + e("Asset has already this name.", nameof(command.FileName)); } }); } @@ -35,6 +35,11 @@ namespace Squidex.Domain.Apps.Entities.Assets.Guards Guard.NotNull(command, nameof(command)); } + public static void CanTag(TagAsset command) + { + Guard.NotNull(command, nameof(command)); + } + public static void CanUpdate(UpdateAsset command) { Guard.NotNull(command, nameof(command)); diff --git a/src/Squidex.Domain.Apps.Entities/Assets/IAssetEntity.cs b/src/Squidex.Domain.Apps.Entities/Assets/IAssetEntity.cs index c61c52cc0..02890b87e 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/IAssetEntity.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/IAssetEntity.cs @@ -16,6 +16,7 @@ namespace Squidex.Domain.Apps.Entities.Assets IEntityWithCreatedBy, IEntityWithLastModifiedBy, IEntityWithVersion, + IEntityWithTags, IAssetInfo { NamedId AppId { get; } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/IAssetGrain.cs b/src/Squidex.Domain.Apps.Entities/Assets/IAssetGrain.cs new file mode 100644 index 000000000..4018d7e3d --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/IAssetGrain.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Orleans; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public interface IAssetGrain : IDomainObjectGrain + { + Task> GetStateAsync(long version = EtagVersion.Any); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs b/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs new file mode 100644 index 000000000..fa17c0731 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs @@ -0,0 +1,20 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public interface IAssetQueryService + { + Task> QueryAsync(QueryContext contex, Q query); + + Task FindAssetAsync(QueryContext context, Guid id); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/ImageTagGenerator.cs b/src/Squidex.Domain.Apps.Entities/Assets/ImageTagGenerator.cs new file mode 100644 index 000000000..c44b7073a --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/ImageTagGenerator.cs @@ -0,0 +1,39 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using Squidex.Domain.Apps.Entities.Assets.Commands; +using Squidex.Domain.Apps.Entities.Tags; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public sealed class ImageTagGenerator : ITagGenerator + { + public void GenerateTags(CreateAsset source, HashSet tags) + { + if (source.ImageInfo != null) + { + tags.Add("image"); + + var wh = source.ImageInfo.PixelWidth + source.ImageInfo.PixelHeight; + + if (wh > 2000) + { + tags.Add("image/large"); + } + else if (wh > 1000) + { + tags.Add("image/medium"); + } + else + { + tags.Add("image/small"); + } + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Queries/FilterTagTransformer.cs b/src/Squidex.Domain.Apps.Entities/Assets/Queries/FilterTagTransformer.cs new file mode 100644 index 000000000..17f201e0d --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/Queries/FilterTagTransformer.cs @@ -0,0 +1,50 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.Tags; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Queries; + +namespace Squidex.Domain.Apps.Entities.Assets.Queries +{ + public sealed class FilterTagTransformer : TransformVisitor + { + private readonly ITagService tagService; + private readonly Guid appId; + + private FilterTagTransformer(Guid appId, ITagService tagService) + { + this.appId = appId; + + this.tagService = tagService; + } + + public static FilterNode Transform(FilterNode nodeIn, Guid appId, ITagService tagService) + { + Guard.NotNull(tagService, nameof(tagService)); + + return nodeIn.Accept(new FilterTagTransformer(appId, tagService)); + } + + public override FilterNode Visit(FilterComparison nodeIn) + { + if (string.Equals(nodeIn.Lhs[0], nameof(IAssetEntity.Tags), StringComparison.OrdinalIgnoreCase) && nodeIn.Rhs.Value is string stringValue) + { + var tagNames = Task.Run(() => tagService.GetTagIdsAsync(appId, TagGroups.Assets, HashSet.Of(stringValue))).Result; + + if (tagNames.TryGetValue(stringValue, out var normalized)) + { + return new FilterComparison(nodeIn.Lhs, nodeIn.Operator, new FilterValue(normalized)); + } + } + + return nodeIn; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs b/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs index f748b4936..e3e86324d 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs @@ -9,15 +9,18 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; using Squidex.Infrastructure; +using Squidex.Infrastructure.Queries; namespace Squidex.Domain.Apps.Entities.Assets.Repositories { public interface IAssetRepository { - Task> QueryAsync(Guid appId, string query = null); + Task> QueryAsync(Guid appId, Query query); Task> QueryAsync(Guid appId, HashSet ids); Task FindAssetAsync(Guid id); + + Task RemoveAsync(Guid appId); } } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs b/src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs index 3ed7714a7..44693da64 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs @@ -6,6 +6,7 @@ // ========================================================================== using System; +using System.Collections.Generic; using Newtonsoft.Json; using Squidex.Domain.Apps.Core.ValidateContent; using Squidex.Domain.Apps.Events; @@ -17,9 +18,7 @@ using Squidex.Infrastructure.Reflection; namespace Squidex.Domain.Apps.Entities.Assets.State { - public class AssetState : DomainObjectState, - IAssetEntity, - IAssetInfo + public class AssetState : DomainObjectState, IAssetEntity { [JsonProperty] public NamedId AppId { get; set; } @@ -51,6 +50,9 @@ namespace Squidex.Domain.Apps.Entities.Assets.State [JsonProperty] public bool IsDeleted { get; set; } + [JsonProperty] + public HashSet Tags { get; set; } + Guid IAssetInfo.AssetId { get { return Id; } @@ -72,6 +74,11 @@ namespace Squidex.Domain.Apps.Entities.Assets.State TotalSize += @event.FileSize; } + protected void On(AssetTagged @event) + { + Tags = @event.Tags; + } + protected void On(AssetRenamed @event) { FileName = @event.FileName; diff --git a/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs b/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs new file mode 100644 index 000000000..21067ce66 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs @@ -0,0 +1,256 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NodaTime; +using Orleans.Concurrency; +using Squidex.Domain.Apps.Entities.Backup.Helpers; +using Squidex.Domain.Apps.Entities.Backup.State; +using Squidex.Domain.Apps.Events; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Assets; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Orleans; +using Squidex.Infrastructure.States; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + [Reentrant] + public sealed class BackupGrain : GrainOfGuid, IBackupGrain + { + private const int MaxBackups = 10; + private static readonly Duration UpdateDuration = Duration.FromSeconds(1); + private readonly IAssetStore assetStore; + private readonly IBackupArchiveLocation backupArchiveLocation; + private readonly IClock clock; + private readonly IEnumerable handlers; + private readonly IEventDataFormatter eventDataFormatter; + private readonly IEventStore eventStore; + private readonly ISemanticLog log; + private readonly IStore store; + private CancellationTokenSource currentTask; + private BackupStateJob currentJob; + private BackupState state = new BackupState(); + private Guid appId; + private IPersistence persistence; + + public BackupGrain( + IAssetStore assetStore, + IBackupArchiveLocation backupArchiveLocation, + IClock clock, + IEventStore eventStore, + IEventDataFormatter eventDataFormatter, + IEnumerable handlers, + ISemanticLog log, + IStore store) + { + Guard.NotNull(assetStore, nameof(assetStore)); + Guard.NotNull(backupArchiveLocation, nameof(backupArchiveLocation)); + Guard.NotNull(clock, nameof(clock)); + Guard.NotNull(eventStore, nameof(eventStore)); + Guard.NotNull(eventDataFormatter, nameof(eventDataFormatter)); + Guard.NotNull(handlers, nameof(handlers)); + Guard.NotNull(store, nameof(store)); + Guard.NotNull(log, nameof(log)); + + this.assetStore = assetStore; + this.backupArchiveLocation = backupArchiveLocation; + this.clock = clock; + this.eventStore = eventStore; + this.eventDataFormatter = eventDataFormatter; + this.handlers = handlers; + this.store = store; + this.log = log; + } + + public override async Task OnActivateAsync(Guid key) + { + appId = key; + + persistence = store.WithSnapshots(GetType(), key, s => state = s); + + await ReadAsync(); + + RecoverAfterRestart(); + } + + private void RecoverAfterRestart() + { + RecoverAfterRestartAsync().Forget(); + } + + private async Task RecoverAfterRestartAsync() + { + foreach (var job in state.Jobs) + { + if (!job.Stopped.HasValue) + { + job.Stopped = clock.GetCurrentInstant(); + + await Safe.DeleteAsync(backupArchiveLocation, job.Id, log); + await Safe.DeleteAsync(assetStore, job.Id, log); + + job.Status = JobStatus.Failed; + + await WriteAsync(); + } + } + } + + public async Task RunAsync() + { + if (currentTask != null) + { + throw new DomainException("Another backup process is already running."); + } + + if (state.Jobs.Count >= MaxBackups) + { + throw new DomainException($"You cannot have more than {MaxBackups} backups."); + } + + var job = new BackupStateJob + { + Id = Guid.NewGuid(), + Started = clock.GetCurrentInstant(), + Status = JobStatus.Started + }; + + currentTask = new CancellationTokenSource(); + currentJob = job; + + var lastTimestamp = job.Started; + + state.Jobs.Insert(0, job); + + await WriteAsync(); + + try + { + using (var stream = await backupArchiveLocation.OpenStreamAsync(job.Id)) + { + using (var writer = new BackupWriter(stream, true)) + { + await eventStore.QueryAsync(async storedEvent => + { + var @event = eventDataFormatter.Parse(storedEvent.Data); + + writer.WriteEvent(storedEvent); + + foreach (var handler in handlers) + { + await handler.BackupEventAsync(@event, appId, writer); + } + + job.HandledEvents = writer.WrittenEvents; + job.HandledAssets = writer.WrittenAttachments; + + lastTimestamp = await WritePeriodically(lastTimestamp); + }, SquidexHeaders.AppId, appId.ToString(), null, currentTask.Token); + + foreach (var handler in handlers) + { + await handler.BackupAsync(appId, writer); + } + + foreach (var handler in handlers) + { + await handler.CompleteBackupAsync(appId, writer); + } + } + + stream.Position = 0; + + currentTask.Token.ThrowIfCancellationRequested(); + + await assetStore.UploadAsync(job.Id.ToString(), 0, null, stream, currentTask.Token); + } + + job.Status = JobStatus.Completed; + } + catch (Exception ex) + { + log.LogError(ex, w => w + .WriteProperty("action", "makeBackup") + .WriteProperty("status", "failed") + .WriteProperty("backupId", job.Id.ToString())); + + job.Status = JobStatus.Failed; + } + finally + { + await Safe.DeleteAsync(backupArchiveLocation, job.Id, log); + + job.Stopped = clock.GetCurrentInstant(); + + await WriteAsync(); + + currentTask = null; + currentJob = null; + } + } + + private async Task WritePeriodically(Instant lastTimestamp) + { + var now = clock.GetCurrentInstant(); + + if ((now - lastTimestamp) >= UpdateDuration) + { + lastTimestamp = now; + + await WriteAsync(); + } + + return lastTimestamp; + } + + public async Task DeleteAsync(Guid id) + { + var job = state.Jobs.FirstOrDefault(x => x.Id == id); + + if (job == null) + { + throw new DomainObjectNotFoundException(id.ToString(), typeof(IBackupJob)); + } + + if (currentJob == job) + { + currentTask?.Cancel(); + } + else + { + await Safe.DeleteAsync(backupArchiveLocation, job.Id, log); + await Safe.DeleteAsync(assetStore, job.Id, log); + + state.Jobs.Remove(job); + + await WriteAsync(); + } + } + + public Task>> GetStateAsync() + { + return J.AsTask(state.Jobs.OfType().ToList()); + } + + private async Task ReadAsync() + { + await persistence.ReadAsync(); + } + + private async Task WriteAsync() + { + await persistence.WriteSnapshotAsync(state); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/BackupHandler.cs b/src/Squidex.Domain.Apps.Entities/Backup/BackupHandler.cs new file mode 100644 index 000000000..b891d58d9 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/BackupHandler.cs @@ -0,0 +1,55 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public abstract class BackupHandler + { + public abstract string Name { get; } + + public virtual Task RestoreEventAsync(Envelope @event, Guid appId, BackupReader reader, RefToken actor) + { + return TaskHelper.True; + } + + public virtual Task BackupEventAsync(Envelope @event, Guid appId, BackupWriter writer) + { + return TaskHelper.Done; + } + + public virtual Task RestoreAsync(Guid appId, BackupReader reader) + { + return TaskHelper.Done; + } + + public virtual Task BackupAsync(Guid appId, BackupWriter writer) + { + return TaskHelper.Done; + } + + public virtual Task CleanupRestoreAsync(Guid appId) + { + return TaskHelper.Done; + } + + public virtual Task CompleteRestoreAsync(Guid appId, BackupReader reader) + { + return TaskHelper.Done; + } + + public virtual Task CompleteBackupAsync(Guid appId, BackupWriter writer) + { + return TaskHelper.Done; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/BackupHandlerWithStore.cs b/src/Squidex.Domain.Apps.Entities/Backup/BackupHandlerWithStore.cs new file mode 100644 index 000000000..d6a2eba0d --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/BackupHandlerWithStore.cs @@ -0,0 +1,60 @@ +// ========================================================================== +// 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 Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.States; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public abstract class BackupHandlerWithStore : BackupHandler + { + private readonly IStore store; + + protected BackupHandlerWithStore(IStore store) + { + Guard.NotNull(store, nameof(store)); + + this.store = store; + } + + protected Task RemoveSnapshotAsync(Guid id) + { + return store.RemoveSnapshotAsync(id); + } + + protected async Task RebuildManyAsync(IEnumerable ids, Func action) + { + foreach (var id in ids) + { + await action(id); + } + } + + protected async Task RebuildAsync(Guid key, Func, TState, TState> func) where TState : IDomainState, new() + { + var state = new TState + { + Version = EtagVersion.Empty + }; + + var persistence = store.WithSnapshotsAndEventSourcing(typeof(TGrain), key, s => state = s, e => + { + state = func(e, state); + + state.Version++; + }); + + await persistence.ReadAsync(); + await persistence.WriteSnapshotAsync(state); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/BackupReader.cs b/src/Squidex.Domain.Apps.Entities/Backup/BackupReader.cs new file mode 100644 index 000000000..32b8faf10 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/BackupReader.cs @@ -0,0 +1,149 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; +using System.IO.Compression; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Entities.Backup.Helpers; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.States; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public sealed class BackupReader : DisposableObjectBase + { + private static readonly JsonSerializer Serializer = new JsonSerializer(); + private readonly GuidMapper guidMapper = new GuidMapper(); + private readonly ZipArchive archive; + private int readEvents; + private int readAttachments; + + public int ReadEvents + { + get { return readEvents; } + } + + public int ReadAttachments + { + get { return readAttachments; } + } + + public BackupReader(Stream stream) + { + archive = new ZipArchive(stream, ZipArchiveMode.Read, false); + } + + protected override void DisposeObject(bool disposing) + { + if (disposing) + { + archive.Dispose(); + } + } + + public Guid OldGuid(Guid newId) + { + return guidMapper.OldGuid(newId); + } + + public async Task ReadJsonAttachmentAsync(string name) + { + Guard.NotNullOrEmpty(name, nameof(name)); + + var attachmentEntry = archive.GetEntry(ArchiveHelper.GetAttachmentPath(name)); + + if (attachmentEntry == null) + { + throw new FileNotFoundException("Cannot find attachment.", name); + } + + JToken result; + + using (var stream = attachmentEntry.Open()) + { + using (var textReader = new StreamReader(stream)) + { + using (var jsonReader = new JsonTextReader(textReader)) + { + result = await JToken.ReadFromAsync(jsonReader); + + guidMapper.NewGuids(result); + } + } + } + + readAttachments++; + + return result; + } + + public async Task ReadBlobAsync(string name, Func handler) + { + Guard.NotNullOrEmpty(name, nameof(name)); + Guard.NotNull(handler, nameof(handler)); + + var attachmentEntry = archive.GetEntry(ArchiveHelper.GetAttachmentPath(name)); + + if (attachmentEntry == null) + { + throw new FileNotFoundException("Cannot find attachment.", name); + } + + using (var stream = attachmentEntry.Open()) + { + await handler(stream); + } + + readAttachments++; + } + + public async Task ReadEventsAsync(IStreamNameResolver streamNameResolver, Func handler) + { + Guard.NotNull(handler, nameof(handler)); + Guard.NotNull(streamNameResolver, nameof(streamNameResolver)); + + while (true) + { + var eventEntry = archive.GetEntry(ArchiveHelper.GetEventPath(readEvents)); + + if (eventEntry == null) + { + break; + } + + using (var stream = eventEntry.Open()) + { + using (var textReader = new StreamReader(stream)) + { + using (var jsonReader = new JsonTextReader(textReader)) + { + var storedEvent = Serializer.Deserialize(jsonReader); + + storedEvent.Data.Payload = guidMapper.NewGuids(storedEvent.Data.Payload); + storedEvent.Data.Metadata = guidMapper.NewGuids(storedEvent.Data.Metadata); + + var streamName = streamNameResolver.WithNewId(storedEvent.StreamName, guidMapper.NewGuidString); + + storedEvent = new StoredEvent(streamName, + storedEvent.EventPosition, + storedEvent.EventStreamNumber, + storedEvent.Data); + + await handler(storedEvent); + } + } + } + + readEvents++; + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/BackupRestoreException.cs b/src/Squidex.Domain.Apps.Entities/Backup/BackupRestoreException.cs new file mode 100644 index 000000000..f7fec5453 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/BackupRestoreException.cs @@ -0,0 +1,31 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Runtime.Serialization; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + [Serializable] + public class BackupRestoreException : Exception + { + public BackupRestoreException(string message) + : base(message) + { + } + + public BackupRestoreException(string message, Exception inner) + : base(message, inner) + { + } + + protected BackupRestoreException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/BackupWriter.cs b/src/Squidex.Domain.Apps.Entities/Backup/BackupWriter.cs new file mode 100644 index 000000000..fa0e793af --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/BackupWriter.cs @@ -0,0 +1,105 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; +using System.IO.Compression; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Entities.Backup.Helpers; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public sealed class BackupWriter : DisposableObjectBase + { + private static readonly JsonSerializer Serializer = new JsonSerializer(); + private readonly ZipArchive archive; + private int writtenEvents; + private int writtenAttachments; + + public int WrittenEvents + { + get { return writtenEvents; } + } + + public int WrittenAttachments + { + get { return writtenAttachments; } + } + + public BackupWriter(Stream stream, bool keepOpen = false) + { + archive = new ZipArchive(stream, ZipArchiveMode.Create, keepOpen); + } + + protected override void DisposeObject(bool disposing) + { + if (disposing) + { + archive.Dispose(); + } + } + + public async Task WriteJsonAsync(string name, JToken value) + { + Guard.NotNullOrEmpty(name, nameof(name)); + + var attachmentEntry = archive.CreateEntry(ArchiveHelper.GetAttachmentPath(name)); + + using (var stream = attachmentEntry.Open()) + { + using (var textWriter = new StreamWriter(stream)) + { + using (var jsonWriter = new JsonTextWriter(textWriter)) + { + await value.WriteToAsync(jsonWriter); + } + } + } + + writtenAttachments++; + } + + public async Task WriteBlobAsync(string name, Func handler) + { + Guard.NotNullOrEmpty(name, nameof(name)); + Guard.NotNull(handler, nameof(handler)); + + var attachmentEntry = archive.CreateEntry(ArchiveHelper.GetAttachmentPath(name)); + + using (var stream = attachmentEntry.Open()) + { + await handler(stream); + } + + writtenAttachments++; + } + + public void WriteEvent(StoredEvent storedEvent) + { + Guard.NotNull(storedEvent, nameof(storedEvent)); + + var eventEntry = archive.CreateEntry(ArchiveHelper.GetEventPath(writtenEvents)); + + using (var stream = eventEntry.Open()) + { + using (var textWriter = new StreamWriter(stream)) + { + using (var jsonWriter = new JsonTextWriter(textWriter)) + { + Serializer.Serialize(jsonWriter, storedEvent); + } + } + } + + writtenEvents++; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/GuidMapper.cs b/src/Squidex.Domain.Apps.Entities/Backup/GuidMapper.cs new file mode 100644 index 000000000..c0f7ac827 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/GuidMapper.cs @@ -0,0 +1,170 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public sealed class GuidMapper + { + private static readonly int GuidLength = Guid.Empty.ToString().Length; + private readonly List<(JObject Source, string NewKey, string OldKey)> mappings = new List<(JObject Source, string NewKey, string OldKey)>(); + private readonly Dictionary oldToNewGuid = new Dictionary(); + private readonly Dictionary newToOldGuid = new Dictionary(); + + public Guid NewGuid(Guid oldGuid) + { + return oldToNewGuid.GetOrDefault(oldGuid); + } + + public Guid OldGuid(Guid newGuid) + { + return newToOldGuid.GetOrDefault(newGuid); + } + + public string NewGuidString(string key) + { + if (Guid.TryParse(key, out var guid)) + { + return GenerateNewGuid(guid).ToString(); + } + + return null; + } + + public JToken NewGuids(JToken jToken) + { + var result = NewGuidsCore(jToken); + + if (mappings.Count > 0) + { + foreach (var mapping in mappings) + { + if (mapping.Source.TryGetValue(mapping.OldKey, out var value)) + { + mapping.Source.Remove(mapping.OldKey); + mapping.Source[mapping.NewKey] = value; + } + } + + mappings.Clear(); + } + + return result; + } + + private JToken NewGuidsCore(JToken jToken) + { + switch (jToken.Type) + { + case JTokenType.String: + if (TryConvertString(jToken.ToString(), out var result)) + { + return result; + } + + break; + case JTokenType.Guid: + return GenerateNewGuid((Guid)jToken); + case JTokenType.Object: + NewGuidsCore((JObject)jToken); + break; + case JTokenType.Array: + NewGuidsCore((JArray)jToken); + break; + } + + return jToken; + } + + private void NewGuidsCore(JArray jArray) + { + for (var i = 0; i < jArray.Count; i++) + { + jArray[i] = NewGuidsCore(jArray[i]); + } + } + + private void NewGuidsCore(JObject jObject) + { + foreach (var jProperty in jObject.Properties()) + { + var newValue = NewGuidsCore(jProperty.Value); + + if (!ReferenceEquals(newValue, jProperty.Value)) + { + jProperty.Value = newValue; + } + + if (TryConvertString(jProperty.Name, out var newKey)) + { + mappings.Add((jObject, newKey, jProperty.Name)); + } + } + } + + private bool TryConvertString(string value, out string result) + { + return TryGenerateNewGuidString(value, out result) || TryGenerateNewNamedId(value, out result); + } + + private bool TryGenerateNewGuidString(string value, out string result) + { + result = null; + + if (value.Length == GuidLength) + { + if (Guid.TryParse(value, out var guid)) + { + var newGuid = GenerateNewGuid(guid); + + result = newGuid.ToString(); + + return true; + } + } + + return false; + } + + private bool TryGenerateNewNamedId(string value, out string result) + { + result = null; + + if (value.Length > GuidLength && value[GuidLength] == ',') + { + if (Guid.TryParse(value.Substring(0, GuidLength), out var guid)) + { + var newGuid = GenerateNewGuid(guid); + + result = newGuid + value.Substring(GuidLength); + + return true; + } + } + + return false; + } + + private Guid GenerateNewGuid(Guid oldGuid) + { + return oldToNewGuid.GetOrAdd(oldGuid, GuidGenerator); + } + + private Guid GuidGenerator(Guid oldGuid) + { + var newGuid = Guid.NewGuid(); + + newToOldGuid[newGuid] = oldGuid; + + return newGuid; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/Helpers/ArchiveHelper.cs b/src/Squidex.Domain.Apps.Entities/Backup/Helpers/ArchiveHelper.cs new file mode 100644 index 000000000..848c90bb2 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/Helpers/ArchiveHelper.cs @@ -0,0 +1,50 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; + +namespace Squidex.Domain.Apps.Entities.Backup.Helpers +{ + public static class ArchiveHelper + { + private const int MaxAttachmentFolders = 1000; + private const int MaxEventsPerFolder = 1000; + + public static string GetAttachmentPath(string name) + { + name = name.ToLowerInvariant(); + + var attachmentFolder = SimpleHash(name) % MaxAttachmentFolders; + var attachmentPath = $"attachments/{attachmentFolder}/{name}"; + + return attachmentPath; + } + + public static string GetEventPath(int index) + { + var eventFolder = index / MaxEventsPerFolder; + var eventPath = $"events/{eventFolder}/{index}.json"; + + return eventPath; + } + + private static int SimpleHash(string value) + { + var hash = 17; + + foreach (var c in value) + { + unchecked + { + hash = (hash * 23) + c.GetHashCode(); + } + } + + return Math.Abs(hash); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/Helpers/Downloader.cs b/src/Squidex.Domain.Apps.Entities/Backup/Helpers/Downloader.cs new file mode 100644 index 000000000..f002efa81 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/Helpers/Downloader.cs @@ -0,0 +1,66 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; + +namespace Squidex.Domain.Apps.Entities.Backup.Helpers +{ + public static class Downloader + { + public static async Task DownloadAsync(this IBackupArchiveLocation backupArchiveLocation, Uri url, Guid id) + { + HttpResponseMessage response = null; + try + { + using (var client = new HttpClient()) + { + response = await client.GetAsync(url); + response.EnsureSuccessStatusCode(); + + using (var sourceStream = await response.Content.ReadAsStreamAsync()) + { + using (var targetStream = await backupArchiveLocation.OpenStreamAsync(id)) + { + await sourceStream.CopyToAsync(targetStream); + } + } + } + } + catch (HttpRequestException ex) + { + throw new BackupRestoreException($"Cannot download the archive. Got status code: {response?.StatusCode}.", ex); + } + } + + public static async Task OpenArchiveAsync(this IBackupArchiveLocation backupArchiveLocation, Guid id) + { + Stream stream = null; + + try + { + stream = await backupArchiveLocation.OpenStreamAsync(id); + + return new BackupReader(stream); + } + catch (IOException) + { + stream?.Dispose(); + + throw new BackupRestoreException("The backup archive is correupt and cannot be opened."); + } + catch (Exception) + { + stream?.Dispose(); + + throw; + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/Helpers/Safe.cs b/src/Squidex.Domain.Apps.Entities/Backup/Helpers/Safe.cs new file mode 100644 index 000000000..d599881b6 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/Helpers/Safe.cs @@ -0,0 +1,62 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Squidex.Infrastructure.Assets; +using Squidex.Infrastructure.Log; + +namespace Squidex.Domain.Apps.Entities.Backup.Helpers +{ + public static class Safe + { + public static async Task DeleteAsync(IBackupArchiveLocation backupArchiveLocation, Guid id, ISemanticLog log) + { + try + { + await backupArchiveLocation.DeleteArchiveAsync(id); + } + catch (Exception ex) + { + log.LogError(ex, w => w + .WriteProperty("action", "deleteArchive") + .WriteProperty("status", "failed") + .WriteProperty("operationId", id.ToString())); + } + } + + public static async Task DeleteAsync(IAssetStore assetStore, Guid id, ISemanticLog log) + { + try + { + await assetStore.DeleteAsync(id.ToString(), 0, null); + } + catch (Exception ex) + { + log.LogError(ex, w => w + .WriteProperty("action", "deleteBackup") + .WriteProperty("status", "failed") + .WriteProperty("operationId", id.ToString())); + } + } + + public static async Task CleanupRestoreAsync(BackupHandler handler, Guid appId, Guid id, ISemanticLog log) + { + try + { + await handler.CleanupRestoreAsync(appId); + } + catch (Exception ex) + { + log.LogError(ex, w => w + .WriteProperty("action", "cleanupRestore") + .WriteProperty("status", "failed") + .WriteProperty("operationId", id.ToString())); + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/IBackupArchiveLocation.cs b/src/Squidex.Domain.Apps.Entities/Backup/IBackupArchiveLocation.cs new file mode 100644 index 000000000..298372a9c --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/IBackupArchiveLocation.cs @@ -0,0 +1,20 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; +using System.Threading.Tasks; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public interface IBackupArchiveLocation + { + Task OpenStreamAsync(Guid backupId); + + Task DeleteArchiveAsync(Guid backupId); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/IBackupGrain.cs b/src/Squidex.Domain.Apps.Entities/Backup/IBackupGrain.cs new file mode 100644 index 000000000..21f66e2ff --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/IBackupGrain.cs @@ -0,0 +1,24 @@ +// ========================================================================== +// 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 Orleans; +using Squidex.Infrastructure.Orleans; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public interface IBackupGrain : IGrainWithGuidKey + { + Task RunAsync(); + + Task DeleteAsync(Guid id); + + Task>> GetStateAsync(); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/IBackupJob.cs b/src/Squidex.Domain.Apps.Entities/Backup/IBackupJob.cs new file mode 100644 index 000000000..56fe0a19e --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/IBackupJob.cs @@ -0,0 +1,27 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using NodaTime; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public interface IBackupJob + { + Guid Id { get; } + + Instant Started { get; } + + Instant? Stopped { get; } + + int HandledEvents { get; } + + int HandledAssets { get; } + + JobStatus Status { get; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/IRestoreGrain.cs b/src/Squidex.Domain.Apps.Entities/Backup/IRestoreGrain.cs new file mode 100644 index 000000000..6ee281401 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/IRestoreGrain.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Orleans; +using Squidex.Infrastructure.Orleans; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public interface IRestoreGrain : IGrainWithStringKey + { + Task RestoreAsync(Uri url, string newAppName = null); + + Task> GetJobAsync(); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/IRestoreJob.cs b/src/Squidex.Domain.Apps.Entities/Backup/IRestoreJob.cs new file mode 100644 index 000000000..fdd5306d8 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/IRestoreJob.cs @@ -0,0 +1,26 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using NodaTime; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public interface IRestoreJob + { + Uri Url { get; } + + Instant Started { get; } + + Instant? Stopped { get; } + + List Log { get; } + + JobStatus Status { get; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/JobStatus.cs b/src/Squidex.Domain.Apps.Entities/Backup/JobStatus.cs new file mode 100644 index 000000000..26f6f541c --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/JobStatus.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public enum JobStatus + { + Created, + Started, + Completed, + Failed + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs b/src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs new file mode 100644 index 000000000..b2f2081c8 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs @@ -0,0 +1,358 @@ +// ========================================================================== +// 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 NodaTime; +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Domain.Apps.Entities.Backup.Helpers; +using Squidex.Domain.Apps.Entities.Backup.State; +using Squidex.Domain.Apps.Events; +using Squidex.Domain.Apps.Events.Apps; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Orleans; +using Squidex.Infrastructure.States; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public sealed class RestoreGrain : GrainOfString, IRestoreGrain + { + private readonly IBackupArchiveLocation backupArchiveLocation; + private readonly IClock clock; + private readonly ICommandBus commandBus; + private readonly IEnumerable handlers; + private readonly IEventStore eventStore; + private readonly IEventDataFormatter eventDataFormatter; + private readonly ISemanticLog log; + private readonly IStreamNameResolver streamNameResolver; + private readonly IStore store; + private RefToken actor; + private RestoreState state = new RestoreState(); + private IPersistence persistence; + + private RestoreStateJob CurrentJob + { + get { return state.Job; } + } + + public RestoreGrain(IBackupArchiveLocation backupArchiveLocation, + IClock clock, + ICommandBus commandBus, + IEventStore eventStore, + IEventDataFormatter eventDataFormatter, + IEnumerable handlers, + ISemanticLog log, + IStreamNameResolver streamNameResolver, + IStore store) + { + Guard.NotNull(backupArchiveLocation, nameof(backupArchiveLocation)); + Guard.NotNull(clock, nameof(clock)); + Guard.NotNull(commandBus, nameof(commandBus)); + Guard.NotNull(eventStore, nameof(eventStore)); + Guard.NotNull(eventDataFormatter, nameof(eventDataFormatter)); + Guard.NotNull(handlers, nameof(handlers)); + Guard.NotNull(store, nameof(store)); + Guard.NotNull(streamNameResolver, nameof(streamNameResolver)); + Guard.NotNull(log, nameof(log)); + + this.backupArchiveLocation = backupArchiveLocation; + this.clock = clock; + this.commandBus = commandBus; + this.eventStore = eventStore; + this.eventDataFormatter = eventDataFormatter; + this.handlers = handlers; + this.store = store; + this.streamNameResolver = streamNameResolver; + this.log = log; + } + + public override async Task OnActivateAsync(string key) + { + actor = new RefToken(RefTokenType.Subject, key); + + persistence = store.WithSnapshots(GetType(), key, s => state = s); + + await ReadAsync(); + + RecoverAfterRestart(); + } + + private void RecoverAfterRestart() + { + RecoverAfterRestartAsync().Forget(); + } + + private async Task RecoverAfterRestartAsync() + { + if (CurrentJob?.Status == JobStatus.Started) + { + Log("Failed due application restart"); + + CurrentJob.Status = JobStatus.Failed; + + await CleanupAsync(); + await WriteAsync(); + } + } + + public Task RestoreAsync(Uri url, string newAppName) + { + Guard.NotNull(url, nameof(url)); + + if (newAppName != null) + { + Guard.ValidSlug(newAppName, nameof(newAppName)); + } + + if (CurrentJob?.Status == JobStatus.Started) + { + throw new DomainException("A restore operation is already running."); + } + + state.Job = new RestoreStateJob + { + Id = Guid.NewGuid(), + NewAppName = newAppName, + Started = clock.GetCurrentInstant(), + Status = JobStatus.Started, + Url = url + }; + + Process(); + + return TaskHelper.Done; + } + + private void Process() + { + ProcessAsync().Forget(); + } + + private async Task ProcessAsync() + { + using (Profiler.StartSession()) + { + try + { + Log("Started. The restore process has the following steps:"); + Log(" * Download backup"); + Log(" * Restore events and attachments."); + Log(" * Restore all objects like app, schemas and contents"); + Log(" * Complete the restore operation for all objects"); + + log.LogInformation(w => w + .WriteProperty("action", "restore") + .WriteProperty("status", "started") + .WriteProperty("operationId", CurrentJob.Id.ToString()) + .WriteProperty("url", CurrentJob.Url.ToString())); + + using (Profiler.Trace("Download")) + { + await DownloadAsync(); + } + + using (var reader = await backupArchiveLocation.OpenArchiveAsync(CurrentJob.Id)) + { + using (Profiler.Trace("ReadEvents")) + { + await ReadEventsAsync(reader); + } + + foreach (var handler in handlers) + { + using (Profiler.TraceMethod(handler.GetType(), nameof(BackupHandler.RestoreAsync))) + { + await handler.RestoreAsync(CurrentJob.AppId, reader); + } + + Log($"Restored {handler.Name}"); + } + + foreach (var handler in handlers) + { + using (Profiler.TraceMethod(handler.GetType(), nameof(BackupHandler.CompleteRestoreAsync))) + { + await handler.CompleteRestoreAsync(CurrentJob.AppId, reader); + } + + Log($"Completed {handler.Name}"); + } + } + + using (Profiler.Trace("AssignContributor")) + { + await AssignContributorAsync(); + + Log("Assigned current user as owner"); + } + + CurrentJob.Status = JobStatus.Completed; + + Log("Completed, Yeah!"); + + log.LogInformation(w => + { + w.WriteProperty("action", "restore"); + w.WriteProperty("status", "completed"); + w.WriteProperty("operationId", CurrentJob.Id.ToString()); + w.WriteProperty("url", CurrentJob.Url.ToString()); + + Profiler.Session?.Write(w); + }); + } + catch (Exception ex) + { + if (ex is BackupRestoreException backupException) + { + Log(backupException.Message); + } + else + { + Log("Failed with internal error"); + } + + await CleanupAsync(); + + CurrentJob.Status = JobStatus.Failed; + + log.LogError(ex, w => + { + w.WriteProperty("action", "retore"); + w.WriteProperty("status", "failed"); + w.WriteProperty("operationId", CurrentJob.Id.ToString()); + w.WriteProperty("url", CurrentJob.Url.ToString()); + + Profiler.Session?.Write(w); + }); + } + finally + { + CurrentJob.Stopped = clock.GetCurrentInstant(); + + await WriteAsync(); + } + } + } + + private async Task AssignContributorAsync() + { + await commandBus.PublishAsync(new AssignContributor + { + Actor = actor, + AppId = CurrentJob.AppId, + ContributorId = actor.Identifier, + FromRestore = true, + Permission = AppContributorPermission.Developer + }); + } + + private async Task CleanupAsync() + { + await Safe.DeleteAsync(backupArchiveLocation, CurrentJob.Id, log); + + if (CurrentJob.AppId != Guid.Empty) + { + foreach (var handler in handlers) + { + await Safe.CleanupRestoreAsync(handler, CurrentJob.AppId, CurrentJob.Id, log); + } + } + } + + private async Task DownloadAsync() + { + Log("Downloading Backup"); + + await backupArchiveLocation.DownloadAsync(CurrentJob.Url, CurrentJob.Id); + + Log("Downloaded Backup"); + } + + private async Task ReadEventsAsync(BackupReader reader) + { + await reader.ReadEventsAsync(streamNameResolver, async storedEvent => + { + var @event = eventDataFormatter.Parse(storedEvent.Data); + + await HandleEventAsync(reader, storedEvent, @event); + }); + + Log("Reading events completed."); + } + + private async Task HandleEventAsync(BackupReader reader, StoredEvent storedEvent, Envelope @event) + { + if (@event.Payload is SquidexEvent squidexEvent) + { + squidexEvent.Actor = actor; + } + + if (@event.Payload is AppCreated appCreated) + { + CurrentJob.AppId = appCreated.AppId.Id; + + if (!string.IsNullOrWhiteSpace(CurrentJob.NewAppName)) + { + appCreated.Name = CurrentJob.NewAppName; + } + } + + if (@event.Payload is AppEvent appEvent && !string.IsNullOrWhiteSpace(CurrentJob.NewAppName)) + { + appEvent.AppId = new NamedId(appEvent.AppId.Id, CurrentJob.NewAppName); + } + + foreach (var handler in handlers) + { + if (!await handler.RestoreEventAsync(@event, CurrentJob.AppId, reader, actor)) + { + return; + } + } + + var eventData = eventDataFormatter.ToEventData(@event, @event.Headers.CommitId()); + var eventCommit = new List { eventData }; + + await eventStore.AppendAsync(Guid.NewGuid(), storedEvent.StreamName, eventCommit); + + Log($"Read {reader.ReadEvents} events and {reader.ReadAttachments} attachments.", true); + } + + private void Log(string message, bool replace = false) + { + if (replace && CurrentJob.Log.Count > 0) + { + CurrentJob.Log[CurrentJob.Log.Count - 1] = $"{clock.GetCurrentInstant()}: {message}"; + } + else + { + CurrentJob.Log.Add($"{clock.GetCurrentInstant()}: {message}"); + } + } + + private async Task ReadAsync() + { + await persistence.ReadAsync(); + } + + private async Task WriteAsync() + { + await persistence.WriteSnapshotAsync(state); + } + + public Task> GetJobAsync() + { + return Task.FromResult>(CurrentJob); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/State/BackupState.cs b/src/Squidex.Domain.Apps.Entities/Backup/State/BackupState.cs new file mode 100644 index 000000000..e75eef133 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/State/BackupState.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace Squidex.Domain.Apps.Entities.Backup.State +{ + public sealed class BackupState + { + [JsonProperty] + public List Jobs { get; } = new List(); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/State/BackupStateJob.cs b/src/Squidex.Domain.Apps.Entities/Backup/State/BackupStateJob.cs new file mode 100644 index 000000000..6da5e9dfa --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/State/BackupStateJob.cs @@ -0,0 +1,34 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Newtonsoft.Json; +using NodaTime; + +namespace Squidex.Domain.Apps.Entities.Backup.State +{ + public sealed class BackupStateJob : IBackupJob + { + [JsonProperty] + public Guid Id { get; set; } + + [JsonProperty] + public Instant Started { get; set; } + + [JsonProperty] + public Instant? Stopped { get; set; } + + [JsonProperty] + public int HandledEvents { get; set; } + + [JsonProperty] + public int HandledAssets { get; set; } + + [JsonProperty] + public JobStatus Status { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreState.cs b/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreState.cs new file mode 100644 index 000000000..d86a658fe --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreState.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Newtonsoft.Json; + +namespace Squidex.Domain.Apps.Entities.Backup.State +{ + public class RestoreState + { + [JsonProperty] + public RestoreStateJob Job { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreStateJob.cs b/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreStateJob.cs new file mode 100644 index 000000000..f3fd17042 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreStateJob.cs @@ -0,0 +1,44 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using NodaTime; + +namespace Squidex.Domain.Apps.Entities.Backup.State +{ + public sealed class RestoreStateJob : IRestoreJob + { + [JsonProperty] + public string AppName { get; set; } + + [JsonProperty] + public Guid Id { get; set; } + + [JsonProperty] + public Guid AppId { get; set; } + + [JsonProperty] + public Uri Url { get; set; } + + [JsonProperty] + public string NewAppName { get; set; } + + [JsonProperty] + public Instant Started { get; set; } + + [JsonProperty] + public Instant? Stopped { get; set; } + + [JsonProperty] + public List Log { get; set; } = new List(); + + [JsonProperty] + public JobStatus Status { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Backup/TempFolderBackupArchiveLocation.cs b/src/Squidex.Domain.Apps.Entities/Backup/TempFolderBackupArchiveLocation.cs new file mode 100644 index 000000000..699ec524a --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Backup/TempFolderBackupArchiveLocation.cs @@ -0,0 +1,44 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; +using System.Threading.Tasks; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Domain.Apps.Entities.Backup +{ + public sealed class TempFolderBackupArchiveLocation : IBackupArchiveLocation + { + public Task OpenStreamAsync(Guid backupId) + { + var tempFile = GetTempFile(backupId); + + return Task.FromResult(new FileStream(tempFile, FileMode.OpenOrCreate, FileAccess.ReadWrite)); + } + + public Task DeleteArchiveAsync(Guid backupId) + { + var tempFile = GetTempFile(backupId); + + try + { + File.Delete(tempFile); + } + catch (IOException) + { + } + + return TaskHelper.Done; + } + + private static string GetTempFile(Guid backupId) + { + return Path.Combine(Path.GetTempPath(), backupId + ".zip"); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/CachingProviderBase.cs b/src/Squidex.Domain.Apps.Entities/CachingProviderBase.cs deleted file mode 100644 index 996646fe8..000000000 --- a/src/Squidex.Domain.Apps.Entities/CachingProviderBase.cs +++ /dev/null @@ -1,29 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Microsoft.Extensions.Caching.Memory; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Entities -{ - public abstract class CachingProviderBase - { - private readonly IMemoryCache cache; - - protected IMemoryCache Cache - { - get { return cache; } - } - - protected CachingProviderBase(IMemoryCache cache) - { - Guard.NotNull(cache, nameof(cache)); - - this.cache = cache; - } - } -} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs b/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs new file mode 100644 index 000000000..dd0f466cb --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs @@ -0,0 +1,49 @@ +// ========================================================================== +// 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 Squidex.Domain.Apps.Entities.Backup; +using Squidex.Domain.Apps.Entities.Contents.State; +using Squidex.Domain.Apps.Events.Contents; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.States; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public sealed class BackupContents : BackupHandlerWithStore + { + private readonly HashSet contentIds = new HashSet(); + + public override string Name { get; } = "Contents"; + + public BackupContents(IStore store) + : base(store) + { + } + + public override Task RestoreEventAsync(Envelope @event, Guid appId, BackupReader reader, RefToken actor) + { + switch (@event.Payload) + { + case ContentCreated contentCreated: + contentIds.Add(contentCreated.ContentId); + break; + } + + return TaskHelper.True; + } + + public override Task RestoreAsync(Guid appId, BackupReader reader) + { + return RebuildManyAsync(contentIds, id => RebuildAsync(id, (e, s) => s.Apply(e))); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Commands/ChangeContentStatus.cs b/src/Squidex.Domain.Apps.Entities/Contents/Commands/ChangeContentStatus.cs index e855a9aff..d388b70e7 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Commands/ChangeContentStatus.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Commands/ChangeContentStatus.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================= +using System; using NodaTime; using Squidex.Domain.Apps.Core.Contents; @@ -15,5 +16,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands public Status Status { get; set; } public Instant? DueTime { get; set; } + + public Guid? JobId { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs b/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs index f2eea4643..7f0842c16 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs @@ -12,5 +12,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands public abstract class ContentDataCommand : ContentCommand { public NamedContentData Data { get; set; } + + public bool AsDraft { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Commands/DiscardChanges.cs b/src/Squidex.Domain.Apps.Entities/Contents/Commands/DiscardChanges.cs new file mode 100644 index 000000000..518e18e49 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/Commands/DiscardChanges.cs @@ -0,0 +1,13 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Contents.Commands +{ + public sealed class DiscardChanges : ContentCommand + { + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs index 27e1c1895..a32b203db 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs @@ -30,11 +30,7 @@ namespace Squidex.Domain.Apps.Entities.Contents public Status Status { get; set; } - public Status? ScheduledTo { get; set; } - - public Instant? ScheduledAt { get; set; } - - public RefToken ScheduledBy { get; set; } + public ScheduleJob ScheduleJob { get; set; } public RefToken CreatedBy { get; set; } @@ -42,6 +38,10 @@ namespace Squidex.Domain.Apps.Entities.Contents public NamedContentData Data { get; set; } + public NamedContentData DataDraft { get; set; } + + public bool IsPending { get; set; } + public static ContentEntity Create(CreateContent command, EntityCreatedResult result) { var now = SystemClock.Instance.GetCurrentInstant(); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs index e1ef9b9ba..e01f16486 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs @@ -19,12 +19,14 @@ using Squidex.Domain.Apps.Events.Contents; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.States; namespace Squidex.Domain.Apps.Entities.Contents { - public class ContentGrain : DomainObjectGrain + public sealed class ContentGrain : SquidexDomainObjectGrainLogSnapshots, IContentGrain { private readonly IAppProvider appProvider; private readonly IAssetRepository assetRepository; @@ -33,11 +35,12 @@ namespace Squidex.Domain.Apps.Entities.Contents public ContentGrain( IStore store, + ISemanticLog log, IAppProvider appProvider, IAssetRepository assetRepository, IScriptEngine scriptEngine, IContentRepository contentRepository) - : base(store) + : base(store, log) { Guard.NotNull(appProvider, nameof(appProvider)); Guard.NotNull(scriptEngine, nameof(scriptEngine)); @@ -50,7 +53,7 @@ namespace Squidex.Domain.Apps.Entities.Contents this.contentRepository = contentRepository; } - public override Task ExecuteAsync(IAggregateCommand command) + protected override Task ExecuteAsync(IAggregateCommand command) { VerifyNotDeleted(); @@ -59,133 +62,211 @@ namespace Squidex.Domain.Apps.Entities.Contents case CreateContent createContent: return CreateReturnAsync(createContent, async c => { - GuardContent.CanCreate(c); + var ctx = await CreateContext(c.AppId.Id, c.SchemaId.Id, () => "Failed to create content."); - var operationContext = await CreateContext(c, () => "Failed to create content."); + GuardContent.CanCreate(ctx.Schema, c); + + await ctx.ExecuteScriptAndTransformAsync(x => x.ScriptCreate, "Create", c, c.Data); + await ctx.EnrichAsync(c.Data); + await ctx.ValidateAsync(c.Data); if (c.Publish) { - await operationContext.ExecuteScriptAsync(x => x.ScriptChange, "Published"); + await ctx.ExecuteScriptAsync(x => x.ScriptChange, "Published", c, c.Data); } - await operationContext.ExecuteScriptAndTransformAsync(x => x.ScriptCreate, "Create"); - await operationContext.EnrichAsync(); - await operationContext.ValidateAsync(); - Create(c); - return EntityCreatedResult.Create(c.Data, NewVersion); + return EntityCreatedResult.Create(c.Data, Version); }); case UpdateContent updateContent: - return UpdateReturnAsync(updateContent, async c => + return UpdateReturnAsync(updateContent, c => { GuardContent.CanUpdate(c); - var operationContext = await CreateContext(c, () => "Failed to update content."); - - await operationContext.ValidateAsync(); - await operationContext.ExecuteScriptAndTransformAsync(x => x.ScriptUpdate, "Update"); - - Update(c); - - return new ContentDataChangedResult(Snapshot.Data, NewVersion); + return UpdateAsync(c, x => c.Data, false); }); case PatchContent patchContent: - return UpdateReturnAsync(patchContent, async c => + return UpdateReturnAsync(patchContent, c => { GuardContent.CanPatch(c); - var operationContext = await CreateContext(c, () => "Failed to patch content."); - - await operationContext.ValidatePartialAsync(); - await operationContext.ExecuteScriptAndTransformAsync(x => x.ScriptUpdate, "Patch"); - - Patch(c); - - return new ContentDataChangedResult(Snapshot.Data, NewVersion); + return UpdateAsync(c, c.Data.MergeInto, true); }); - case ChangeContentStatus patchContent: - return UpdateAsync(patchContent, async c => + case ChangeContentStatus changeContentStatus: + return UpdateAsync(changeContentStatus, async c => { - GuardContent.CanChangeContentStatus(Snapshot.Status, c); - - if (!c.DueTime.HasValue) + try { - var operationContext = await CreateContext(c, () => "Failed to patch content."); - - await operationContext.ExecuteScriptAsync(x => x.ScriptChange, c.Status); + var ctx = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, () => "Failed to change content."); + + GuardContent.CanChangeContentStatus(ctx.Schema, Snapshot.IsPending, Snapshot.Status, c); + + if (c.DueTime.HasValue) + { + ScheduleStatus(c); + } + else + { + if (Snapshot.IsPending && Snapshot.Status == Status.Published && c.Status == Status.Published) + { + ConfirmChanges(c); + } + else + { + StatusChange reason; + + if (c.Status == Status.Published) + { + reason = StatusChange.Published; + } + else if (c.Status == Status.Archived) + { + reason = StatusChange.Archived; + } + else if (Snapshot.Status == Status.Published) + { + reason = StatusChange.Unpublished; + } + else + { + reason = StatusChange.Restored; + } + + await ctx.ExecuteScriptAsync(x => x.ScriptChange, reason, c, Snapshot.Data); + + ChangeStatus(c, reason); + } + } + } + catch (Exception) + { + if (c.JobId.HasValue && Snapshot?.ScheduleJob.Id == c.JobId) + { + CancelScheduling(c); + } + else + { + throw; + } } - - ChangeStatus(c); }); case DeleteContent deleteContent: return UpdateAsync(deleteContent, async c => { - GuardContent.CanDelete(c); + var ctx = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, () => "Failed to delete content."); - var operationContext = await CreateContext(c, () => "Failed to delete content."); + GuardContent.CanDelete(ctx.Schema, c); - await operationContext.ExecuteScriptAsync(x => x.ScriptDelete, "Delete"); + await ctx.ExecuteScriptAsync(x => x.ScriptDelete, "Delete", c, Snapshot.Data); Delete(c); }); + case DiscardChanges discardChanges: + return UpdateAsync(discardChanges, c => + { + GuardContent.CanDiscardChanges(Snapshot.IsPending, c); + + DiscardChanges(c); + }); + default: throw new NotSupportedException(); } } + private async Task UpdateAsync(ContentDataCommand c, Func newDataFunc, bool partial) + { + var isProposal = c.AsDraft && Snapshot.Status == Status.Published; + + var currentData = + isProposal ? + Snapshot.DataDraft : + Snapshot.Data; + + var newData = newDataFunc(currentData); + + if (!currentData.Equals(newData)) + { + var ctx = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, () => "Failed to update content."); + + if (partial) + { + await ctx.ValidatePartialAsync(c.Data); + } + else + { + await ctx.ValidateAsync(c.Data); + } + + newData = await ctx.ExecuteScriptAndTransformAsync(x => x.ScriptUpdate, "Update", c, newData, Snapshot.Data); + + if (isProposal) + { + ProposeUpdate(c, newData); + } + else + { + Update(c, newData); + } + } + + return new ContentDataChangedResult(newData, Version); + } + public void Create(CreateContent command) { RaiseEvent(SimpleMapper.Map(command, new ContentCreated())); if (command.Publish) { - RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged { Status = Status.Published })); + RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged { Status = Status.Published, Change = StatusChange.Published })); } } - public void Update(UpdateContent command) + public void ConfirmChanges(ChangeContentStatus command) { - if (!command.Data.Equals(Snapshot.Data)) - { - RaiseEvent(SimpleMapper.Map(command, new ContentUpdated())); - } + RaiseEvent(SimpleMapper.Map(command, new ContentChangesPublished())); } - public void ChangeStatus(ChangeContentStatus command) + public void DiscardChanges(DiscardChanges command) { - if (command.DueTime.HasValue) - { - RaiseEvent(SimpleMapper.Map(command, new ContentStatusScheduled { DueTime = command.DueTime.Value })); - } - else - { - RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged())); - } + RaiseEvent(SimpleMapper.Map(command, new ContentChangesDiscarded())); } - public void Patch(PatchContent command) + public void Delete(DeleteContent command) { - var newData = command.Data.MergeInto(Snapshot.Data); + RaiseEvent(SimpleMapper.Map(command, new ContentDeleted())); + } - if (!newData.Equals(Snapshot.Data)) - { - var @event = SimpleMapper.Map(command, new ContentUpdated()); + public void Update(ContentCommand command, NamedContentData data) + { + RaiseEvent(SimpleMapper.Map(command, new ContentUpdated { Data = data })); + } - @event.Data = newData; + public void ProposeUpdate(ContentCommand command, NamedContentData data) + { + RaiseEvent(SimpleMapper.Map(command, new ContentUpdateProposed { Data = data })); + } - RaiseEvent(@event); - } + public void CancelScheduling(ChangeContentStatus command) + { + RaiseEvent(SimpleMapper.Map(command, new ContentSchedulingCancelled())); } - public void Delete(DeleteContent command) + public void ScheduleStatus(ChangeContentStatus command) { - RaiseEvent(SimpleMapper.Map(command, new ContentDeleted())); + RaiseEvent(SimpleMapper.Map(command, new ContentStatusScheduled { DueTime = command.DueTime.Value })); + } + + public void ChangeStatus(ChangeContentStatus command, StatusChange reason) + { + RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged { Change = reason })); } private void RaiseEvent(SchemaEvent @event) @@ -211,22 +292,24 @@ namespace Squidex.Domain.Apps.Entities.Contents } } - public override void ApplyEvent(Envelope @event) + protected override ContentState OnEvent(Envelope @event) { - ApplySnapshot(Snapshot.Apply(@event)); + return Snapshot.Apply(@event); } - private async Task CreateContext(ContentCommand command, Func message) + private async Task CreateContext(Guid appId, Guid schemaId, Func message) { var operationContext = - await ContentOperationContext.CreateAsync(command, Snapshot, - contentRepository, - appProvider, - assetRepository, - scriptEngine, - message); + await ContentOperationContext.CreateAsync( + appId, schemaId, + appProvider, assetRepository, contentRepository, scriptEngine, message); return operationContext; } + + public Task> GetStateAsync(long version = EtagVersion.Any) + { + return J.AsTask(GetSnapshot(version)); + } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentHistoryEventsCreator.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentHistoryEventsCreator.cs index 53b391311..3b7260f3f 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentHistoryEventsCreator.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentHistoryEventsCreator.cs @@ -20,16 +20,31 @@ namespace Squidex.Domain.Apps.Entities.Contents : base(typeNameRegistry) { AddEventMessage( - "created {[Schema]} content item."); + "created {[Schema]} content."); AddEventMessage( - "updated {[Schema]} content item."); + "updated {[Schema]} content."); AddEventMessage( - "deleted {[Schema]} content item."); + "deleted {[Schema]} content."); + + AddEventMessage( + "discarded pending changes of {[Schema]} content."); + + AddEventMessage( + "published changes of {[Schema]} content."); + + AddEventMessage( + "proposed update for {[Schema]} content."); + + AddEventMessage( + "failed to schedule status change for {[Schema]} content."); AddEventMessage( - "changed status of {[Schema]} content item to {[Status]}."); + "changed status of {[Schema]} content to {[Status]}."); + + AddEventMessage( + "scheduled to change status of {[Schema]} content to {[Status]}."); } protected override Task CreateEventCoreAsync(Envelope @event) @@ -48,6 +63,11 @@ namespace Squidex.Domain.Apps.Entities.Contents result = result.AddParameter("Status", contentStatusChanged.Status); } + if (@event.Payload is ContentStatusScheduled contentStatusScheduled) + { + result = result.AddParameter("Status", contentStatusScheduled.Status); + } + return Task.FromResult(result); } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs index 3cc500552..5ca66b9ef 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs @@ -24,44 +24,34 @@ namespace Squidex.Domain.Apps.Entities.Contents { public sealed class ContentOperationContext { - private ContentCommand command; private IContentRepository contentRepository; - private IContentEntity content; private IAssetRepository assetRepository; private IScriptEngine scriptEngine; private ISchemaEntity schemaEntity; private IAppEntity appEntity; - private Guid appId; private Func message; + public ISchemaEntity Schema + { + get { return schemaEntity; } + } + public static async Task CreateAsync( - ContentCommand command, - IContentEntity content, - IContentRepository contentRepository, + Guid appId, + Guid schemaId, IAppProvider appProvider, IAssetRepository assetRepository, + IContentRepository contentRepository, IScriptEngine scriptEngine, Func message) { - var a = content.AppId; - var s = content.SchemaId; - - if (command is CreateContent createContent) - { - a = a ?? createContent.AppId; - s = s ?? createContent.SchemaId; - } - - var (appEntity, schemaEntity) = await appProvider.GetAppWithSchemaAsync(a.Id, s.Id); + var (appEntity, schemaEntity) = await appProvider.GetAppWithSchemaAsync(appId, schemaId); var context = new ContentOperationContext { appEntity = appEntity, - appId = a.Id, assetRepository = assetRepository, contentRepository = contentRepository, - content = content, - command = command, message = message, schemaEntity = schemaEntity, scriptEngine = scriptEngine @@ -70,87 +60,63 @@ namespace Squidex.Domain.Apps.Entities.Contents return context; } - public Task EnrichAsync() + public Task EnrichAsync(NamedContentData data) { - if (command is ContentDataCommand dataCommand) - { - dataCommand.Data.Enrich(schemaEntity.SchemaDef, appEntity.PartitionResolver()); - } + data.Enrich(schemaEntity.SchemaDef, appEntity.PartitionResolver()); return TaskHelper.Done; } - public Task ValidateAsync() + public Task ValidateAsync(NamedContentData data) { - if (command is ContentDataCommand dataCommand) - { - var ctx = CreateValidationContext(); - - return dataCommand.Data.ValidateAsync(ctx, schemaEntity.SchemaDef, appEntity.PartitionResolver(), message); - } + var ctx = CreateValidationContext(); - return TaskHelper.Done; + return data.ValidateAsync(ctx, schemaEntity.SchemaDef, appEntity.PartitionResolver(), message); } - public Task ValidatePartialAsync() + public Task ValidatePartialAsync(NamedContentData data) { - if (command is ContentDataCommand dataCommand) - { - var ctx = CreateValidationContext(); + var ctx = CreateValidationContext(); - return dataCommand.Data.ValidatePartialAsync(ctx, schemaEntity.SchemaDef, appEntity.PartitionResolver(), message); - } - - return TaskHelper.Done; + return data.ValidatePartialAsync(ctx, schemaEntity.SchemaDef, appEntity.PartitionResolver(), message); } - public Task ExecuteScriptAndTransformAsync(Func script, object operation) + public Task ExecuteScriptAndTransformAsync(Func script, object operation, ContentCommand command, NamedContentData data, NamedContentData oldData = null) { - if (command is ContentDataCommand dataCommand) - { - var ctx = CreateScriptContext(operation, dataCommand.Data); + var ctx = CreateScriptContext(operation, command, data, oldData); - dataCommand.Data = scriptEngine.ExecuteAndTransform(ctx, script(schemaEntity)); - } + var result = scriptEngine.ExecuteAndTransform(ctx, script(schemaEntity)); - return TaskHelper.Done; + return Task.FromResult(result); } - public Task ExecuteScriptAsync(Func script, object operation) + public Task ExecuteScriptAsync(Func script, object operation, ContentCommand command, NamedContentData data, NamedContentData oldData = null) { - var ctx = CreateScriptContext(operation, content.Data); + var ctx = CreateScriptContext(operation, command, data, oldData); scriptEngine.Execute(ctx, script(schemaEntity)); return TaskHelper.Done; } - private ScriptContext CreateScriptContext(object operation, NamedContentData data = null) + private static ScriptContext CreateScriptContext(object operation, ContentCommand command, NamedContentData data, NamedContentData oldData) { - return new ScriptContext { ContentId = command.ContentId, OldData = content.Data, Data = data, User = command.User, Operation = operation.ToString() }; + return new ScriptContext { ContentId = command.ContentId, OldData = oldData, Data = data, User = command.User, Operation = operation.ToString() }; } private ValidationContext CreateValidationContext() { - return new ValidationContext( - (contentIds, schemaId) => - { - return QueryContentsAsync(schemaId, contentIds); - }, - assetIds => - { - return QueryAssetsAsync(assetIds); - }); + return new ValidationContext((contentIds, schemaId) => QueryContentsAsync(schemaId, contentIds), QueryAssetsAsync); } private async Task> QueryAssetsAsync(IEnumerable assetIds) { - return await assetRepository.QueryAsync(appId, new HashSet(assetIds)); + return await assetRepository.QueryAsync(appEntity.Id, new HashSet(assetIds)); } private async Task> QueryContentsAsync(Guid schemaId, IEnumerable contentIds) { - return await contentRepository.QueryNotFoundAsync(appId, schemaId, contentIds.ToList()); + return await contentRepository.QueryNotFoundAsync(appEntity.Id, schemaId, contentIds.ToList()); } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryContext.cs new file mode 100644 index 000000000..547cab20e --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryContext.cs @@ -0,0 +1,51 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public sealed class ContentQueryContext : Cloneable + { + public string SchemaIdOrName { get; private set; } + + public QueryContext Base { get; private set; } + + public ContentQueryContext(QueryContext @base) + { + Guard.NotNull(@base, nameof(@base)); + + Base = @base; + } + + public ContentQueryContext WithSchemaName(string name) + { + return Clone(c => c.SchemaIdOrName = name); + } + + public ContentQueryContext WithArchived(bool archived) + { + return Clone(c => c.Base = c.Base.WithArchived(archived)); + } + + public ContentQueryContext WithFlatten(bool flatten) + { + return Clone(c => c.Base = c.Base.WithFlatten(flatten)); + } + + public ContentQueryContext WithUnpublished(bool unpublished) + { + return Clone(c => c.Base = c.Base.WithUnpublished(unpublished)); + } + + public ContentQueryContext WithSchemaId(Guid id) + { + return Clone(c => c.SchemaIdOrName = id.ToString()); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs index ebd65e858..62ec24a95 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs @@ -8,195 +8,302 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Security.Claims; using System.Threading.Tasks; using Microsoft.OData; -using Microsoft.OData.UriParser; using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.ConvertContent; using Squidex.Domain.Apps.Core.Scripting; -using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Contents.Edm; using Squidex.Domain.Apps.Entities.Contents.Repositories; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Queries; +using Squidex.Infrastructure.Queries.OData; using Squidex.Infrastructure.Reflection; -using Squidex.Infrastructure.Security; + +#pragma warning disable RECS0147 namespace Squidex.Domain.Apps.Entities.Contents { public sealed class ContentQueryService : IContentQueryService { + private const int MaxResults = 200; + private static readonly Status[] StatusAll = { Status.Archived, Status.Draft, Status.Published }; + private static readonly Status[] StatusArchived = { Status.Archived }; + private static readonly Status[] StatusPublished = { Status.Published }; + private static readonly Status[] StatusDraftOrPublished = { Status.Draft, Status.Published }; private readonly IContentRepository contentRepository; + private readonly IContentVersionLoader contentVersionLoader; private readonly IAppProvider appProvider; private readonly IScriptEngine scriptEngine; private readonly EdmModelBuilder modelBuilder; public ContentQueryService( - IContentRepository contentRepository, IAppProvider appProvider, + IContentRepository contentRepository, + IContentVersionLoader contentVersionLoader, IScriptEngine scriptEngine, EdmModelBuilder modelBuilder) { + Guard.NotNull(appProvider, nameof(appProvider)); Guard.NotNull(contentRepository, nameof(contentRepository)); - Guard.NotNull(scriptEngine, nameof(scriptEngine)); + Guard.NotNull(contentVersionLoader, nameof(contentVersionLoader)); Guard.NotNull(modelBuilder, nameof(modelBuilder)); - Guard.NotNull(appProvider, nameof(appProvider)); + Guard.NotNull(scriptEngine, nameof(scriptEngine)); - this.contentRepository = contentRepository; this.appProvider = appProvider; - this.scriptEngine = scriptEngine; + this.contentRepository = contentRepository; + this.contentVersionLoader = contentVersionLoader; this.modelBuilder = modelBuilder; + this.scriptEngine = scriptEngine; } - public async Task<(ISchemaEntity Schema, IContentEntity Content)> FindContentAsync(IAppEntity app, string schemaIdOrName, ClaimsPrincipal user, Guid id, long version = -1) + public Task ThrowIfSchemaNotExistsAsync(ContentQueryContext context) { - Guard.NotNull(app, nameof(app)); - Guard.NotNull(user, nameof(user)); - Guard.NotNullOrEmpty(schemaIdOrName, nameof(schemaIdOrName)); - - var isFrontendClient = user.IsInClient("squidex-frontend"); + return GetSchemaAsync(context); + } - var schema = await FindSchemaAsync(app, schemaIdOrName); + public async Task FindContentAsync(ContentQueryContext context, Guid id, long version = -1) + { + Guard.NotNull(context, nameof(context)); - var content = - version > EtagVersion.Empty ? - await contentRepository.FindContentAsync(app, schema, id, version) : - await contentRepository.FindContentAsync(app, schema, id); + var schema = await GetSchemaAsync(context); - if (content == null || (content.Status != Status.Published && !isFrontendClient)) + using (Profiler.TraceMethod()) { - throw new DomainObjectNotFoundException(id.ToString(), typeof(ISchemaEntity)); - } + var isVersioned = version > EtagVersion.Empty; + + var status = GetFindStatus(context.Base); - content = TransformContent(user, schema, Enumerable.Repeat(content, 1)).FirstOrDefault(); + var content = + isVersioned ? + await FindContentByVersionAsync(id, version) : + await FindContentAsync(context.Base, id, status, schema); - return (schema, content); + if (content == null || (content.Status != Status.Published && !context.Base.IsFrontendClient) || content.SchemaId.Id != schema.Id) + { + throw new DomainObjectNotFoundException(id.ToString(), typeof(ISchemaEntity)); + } + + return Transform(context.Base, schema, true, content); + } } - public async Task<(ISchemaEntity Schema, IResultList Contents)> QueryAsync(IAppEntity app, string schemaIdOrName, ClaimsPrincipal user, bool archived, string query) + public async Task> QueryAsync(ContentQueryContext context, Q query) { - Guard.NotNull(app, nameof(app)); - Guard.NotNull(user, nameof(user)); - Guard.NotNullOrEmpty(schemaIdOrName, nameof(schemaIdOrName)); + Guard.NotNull(context, nameof(context)); + + var schema = await GetSchemaAsync(context); - var schema = await FindSchemaAsync(app, schemaIdOrName); + using (Profiler.TraceMethod()) + { + var status = GetQueryStatus(context.Base); - var parsedQuery = ParseQuery(app, query, schema); - var parsedStatus = ParseStatus(user, archived); + IResultList contents; - var contents = await contentRepository.QueryAsync(app, schema, parsedStatus.ToArray(), parsedQuery); + if (query.Ids?.Count > 0) + { + contents = await contentRepository.QueryAsync(context.Base.App, schema, status, new HashSet(query.Ids)); + contents = Sort(contents, query.Ids); + } + else + { + var parsedQuery = ParseQuery(context.Base, query.ODataQuery, schema); - return TransformContents(user, schema, contents); + contents = await contentRepository.QueryAsync(context.Base.App, schema, status, parsedQuery); + } + + return Transform(context.Base, schema, true, contents); + } } - public async Task<(ISchemaEntity Schema, IResultList Contents)> QueryAsync(IAppEntity app, string schemaIdOrName, ClaimsPrincipal user, bool archived, HashSet ids) + private IContentEntity Transform(QueryContext context, ISchemaEntity schema, bool checkType, IContentEntity content) { - Guard.NotNull(ids, nameof(ids)); - Guard.NotNull(app, nameof(app)); - Guard.NotNull(user, nameof(user)); - Guard.NotNullOrEmpty(schemaIdOrName, nameof(schemaIdOrName)); - - var schema = await FindSchemaAsync(app, schemaIdOrName); - - var parsedStatus = ParseStatus(user, archived); + return TansformCore(context, schema, checkType, Enumerable.Repeat(content, 1)).FirstOrDefault(); + } - var contents = await contentRepository.QueryAsync(app, schema, parsedStatus.ToArray(), ids); + private IResultList Transform(QueryContext context, ISchemaEntity schema, bool checkType, IResultList contents) + { + var transformed = TansformCore(context, schema, checkType, contents); - return TransformContents(user, schema, contents); + return ResultList.Create(contents.Total, transformed); } - private (ISchemaEntity Schema, IResultList Contents) TransformContents(ClaimsPrincipal user, ISchemaEntity schema, IResultList contents) + private static IResultList Sort(IResultList contents, IReadOnlyList ids) { - var transformed = TransformContent(user, schema, contents); + var sorted = ids.Select(id => contents.FirstOrDefault(x => x.Id == id)).Where(x => x != null); - return (schema, ResultList.Create(transformed, contents.Total)); + return ResultList.Create(contents.Total, sorted); } - private IEnumerable TransformContent(ClaimsPrincipal user, ISchemaEntity schema, IEnumerable contents) + private IEnumerable TansformCore(QueryContext context, ISchemaEntity schema, bool checkType, IEnumerable contents) { - var scriptText = schema.ScriptQuery; - - if (!string.IsNullOrWhiteSpace(scriptText)) + using (Profiler.TraceMethod()) { + var converters = GenerateConverters(context, checkType).ToArray(); + + var scriptText = schema.ScriptQuery; + + var isScripting = !string.IsNullOrWhiteSpace(scriptText); + 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 ContentEntity()); + var result = SimpleMapper.Map(content, new ContentEntity()); - contentResult.Data = contentData; + if (result.Data != null) + { + if (!context.IsFrontendClient && isScripting) + { + result.Data = scriptEngine.Transform(new ScriptContext { User = context.User, Data = content.Data, ContentId = content.Id }, scriptText); + } - yield return contentResult; + result.Data = result.Data.ConvertName2Name(schema.SchemaDef, converters); + } + + if (result.DataDraft != null) + { + result.DataDraft = result.DataDraft.ConvertName2Name(schema.SchemaDef, converters); + } + + yield return result; } } - else + } + + private static IEnumerable GenerateConverters(QueryContext context, bool checkType) + { + if (!context.IsFrontendClient) { - foreach (var content in contents) + yield return FieldConverters.ExcludeHidden(); + yield return FieldConverters.ForNestedName2Name(ValueConverters.ExcludeHidden()); + } + + if (checkType) + { + yield return FieldConverters.ExcludeChangedTypes(); + yield return FieldConverters.ForNestedName2Name(ValueConverters.ExcludeChangedTypes()); + } + + yield return FieldConverters.ResolveInvariant(context.App.LanguagesConfig); + yield return FieldConverters.ResolveLanguages(context.App.LanguagesConfig); + + if (!context.IsFrontendClient) + { + yield return FieldConverters.ResolveFallbackLanguages(context.App.LanguagesConfig); + + if (context.Languages?.Any() == true) { - yield return content; + yield return FieldConverters.FilterLanguages(context.App.LanguagesConfig, context.Languages); } } } - private ODataUriParser ParseQuery(IAppEntity app, string query, ISchemaEntity schema) + private Query ParseQuery(QueryContext context, string query, ISchemaEntity schema) { - try + using (Profiler.TraceMethod()) { - var model = modelBuilder.BuildEdmModel(schema, app); + try + { + var model = modelBuilder.BuildEdmModel(schema, context.App); - return model.ParseQuery(query); - } - catch (ODataException ex) - { - throw new ValidationException($"Failed to parse query: {ex.Message}", ex); + var result = model.ParseQuery(query).ToQuery(); + + if (result.Sort.Count == 0) + { + result.Sort.Add(new SortNode(new List { "lastModified" }, SortOrder.Descending)); + } + + if (result.Take > MaxResults) + { + result.Take = MaxResults; + } + + return result; + } + catch (NotSupportedException) + { + throw new ValidationException("OData operation is not supported."); + } + catch (ODataException ex) + { + throw new ValidationException($"Failed to parse query: {ex.Message}", ex); + } } } - public async Task FindSchemaAsync(IAppEntity app, string schemaIdOrName) + public async Task GetSchemaAsync(ContentQueryContext context) { - Guard.NotNull(app, nameof(app)); - ISchemaEntity schema = null; - if (Guid.TryParse(schemaIdOrName, out var id)) + if (Guid.TryParse(context.SchemaIdOrName, out var id)) { - schema = await appProvider.GetSchemaAsync(app.Id, id); + schema = await appProvider.GetSchemaAsync(context.Base.App.Id, id); } if (schema == null) { - schema = await appProvider.GetSchemaAsync(app.Id, schemaIdOrName); + schema = await appProvider.GetSchemaAsync(context.Base.App.Id, context.SchemaIdOrName); } if (schema == null) { - throw new DomainObjectNotFoundException(schemaIdOrName, typeof(ISchemaEntity)); + throw new DomainObjectNotFoundException(context.SchemaIdOrName, typeof(ISchemaEntity)); } return schema; } - private static List ParseStatus(ClaimsPrincipal user, bool archived) + private static Status[] GetFindStatus(QueryContext context) { - var status = new List(); + if (context.IsFrontendClient) + { + return StatusAll; + } + else if (context.Unpublished) + { + return StatusDraftOrPublished; + } + else + { + return StatusPublished; + } + } - if (user.IsInClient("squidex-frontend")) + private static Status[] GetQueryStatus(QueryContext context) + { + if (context.IsFrontendClient) { - if (archived) + if (context.Archived) { - status.Add(Status.Archived); + return StatusArchived; } else { - status.Add(Status.Draft); - status.Add(Status.Published); + return StatusDraftOrPublished; } } else { - status.Add(Status.Published); + if (context.Unpublished) + { + return StatusDraftOrPublished; + } + else + { + return StatusPublished; + } } + } - return status; + private Task FindContentByVersionAsync(Guid id, long version) + { + return contentVersionLoader.LoadAsync(id, version); + } + + private Task FindContentAsync(QueryContext context, Guid id, Status[] status, ISchemaEntity schema) + { + return contentRepository.FindContentAsync(context.App, schema, status, id); } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentScheduler.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentScheduler.cs deleted file mode 100644 index 23d3c05a9..000000000 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentScheduler.cs +++ /dev/null @@ -1,57 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Threading.Tasks; -using NodaTime; -using Squidex.Domain.Apps.Entities.Contents.Commands; -using Squidex.Domain.Apps.Entities.Contents.Repositories; -using Squidex.Infrastructure; -using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.Timers; - -namespace Squidex.Domain.Apps.Entities.Contents -{ - public sealed class ContentScheduler : IRunnable - { - private readonly CompletionTimer timer; - private readonly IContentRepository contentRepository; - private readonly ICommandBus commandBus; - private readonly IClock clock; - - public ContentScheduler( - IContentRepository contentRepository, - ICommandBus commandBus, - IClock clock) - { - Guard.NotNull(contentRepository, nameof(contentRepository)); - Guard.NotNull(commandBus, nameof(commandBus)); - Guard.NotNull(clock, nameof(clock)); - - this.contentRepository = contentRepository; - this.commandBus = commandBus; - this.clock = clock; - - timer = new CompletionTimer(5000, x => PublishAsync()); - } - - public void Run() - { - } - - private Task PublishAsync() - { - var now = clock.GetCurrentInstant(); - - return contentRepository.QueryScheduledWithoutDataAsync(now, content => - { - var command = new ChangeContentStatus { ContentId = content.Id, Status = content.ScheduledTo.Value, Actor = content.ScheduledBy }; - - return commandBus.PublishAsync(command); - }); - } - } -} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentSchedulerGrain.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentSchedulerGrain.cs new file mode 100644 index 000000000..4f2f92982 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentSchedulerGrain.cs @@ -0,0 +1,105 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading; +using System.Threading.Tasks; +using NodaTime; +using Orleans; +using Orleans.Runtime; +using Squidex.Domain.Apps.Entities.Contents.Commands; +using Squidex.Domain.Apps.Entities.Contents.Repositories; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public sealed class ContentSchedulerGrain : Grain, IContentSchedulerGrain, IRemindable + { + private readonly Lazy contentRepository; + private readonly Lazy commandBus; + private readonly IClock clock; + private readonly ISemanticLog log; + private TaskScheduler scheduler; + + public ContentSchedulerGrain( + Lazy contentRepository, + Lazy commandBus, + IClock clock, + ISemanticLog log) + { + Guard.NotNull(contentRepository, nameof(contentRepository)); + Guard.NotNull(commandBus, nameof(commandBus)); + Guard.NotNull(clock, nameof(clock)); + Guard.NotNull(log, nameof(log)); + + this.contentRepository = contentRepository; + this.commandBus = commandBus; + this.clock = clock; + this.log = log; + } + + public override Task OnActivateAsync() + { + scheduler = TaskScheduler.Current; + + DelayDeactivation(TimeSpan.FromDays(1)); + + RegisterOrUpdateReminder("Default", TimeSpan.Zero, TimeSpan.FromMinutes(10)); + RegisterTimer(x => PublishAsync(), null, TimeSpan.Zero, TimeSpan.FromSeconds(10)); + + return Task.FromResult(true); + } + + public Task ActivateAsync() + { + return TaskHelper.Done; + } + + public Task PublishAsync() + { + var now = clock.GetCurrentInstant(); + + return contentRepository.Value.QueryScheduledWithoutDataAsync(now, content => + { + return Dispatch(async () => + { + try + { + var job = content.ScheduleJob; + + if (job != null) + { + var command = new ChangeContentStatus { ContentId = content.Id, Status = job.Status, Actor = job.ScheduledBy, JobId = job.Id }; + + await commandBus.Value.PublishAsync(command); + } + } + catch (Exception ex) + { + log.LogError(ex, w => w + .WriteProperty("action", "ChangeStatusScheduled") + .WriteProperty("status", "Failed") + .WriteProperty("contentId", content.Id.ToString())); + } + }); + }); + } + + public Task ReceiveReminder(string reminderName, TickStatus status) + { + return TaskHelper.Done; + } + + private Task Dispatch(Func task) + { + return Task.Factory.StartNew(task, CancellationToken.None, TaskCreationOptions.None, scheduler ?? TaskScheduler.Default).Unwrap(); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentVersionLoader.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentVersionLoader.cs new file mode 100644 index 000000000..7016766ef --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentVersionLoader.cs @@ -0,0 +1,44 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Orleans; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Log; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public sealed class ContentVersionLoader : IContentVersionLoader + { + private readonly IGrainFactory grainFactory; + + public ContentVersionLoader(IGrainFactory grainFactory) + { + Guard.NotNull(grainFactory, nameof(grainFactory)); + + this.grainFactory = grainFactory; + } + + public async Task LoadAsync(Guid id, long version) + { + using (Profiler.TraceMethod()) + { + var grain = grainFactory.GetGrain(id); + + var content = await grain.GetStateAsync(version); + + if (content.Value == null || content.Value.Version != version) + { + throw new DomainObjectNotFoundException(id.ToString(), typeof(IContentEntity)); + } + + return content.Value; + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Edm/EdmModelBuilder.cs b/src/Squidex.Domain.Apps.Entities/Contents/Edm/EdmModelBuilder.cs index ea8ffbe18..f2d9c133f 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Edm/EdmModelBuilder.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Edm/EdmModelBuilder.cs @@ -19,6 +19,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.Edm { public class EdmModelBuilder : CachingProviderBase { + private static readonly TimeSpan CacheTime = TimeSpan.FromMinutes(60); + public EdmModelBuilder(IMemoryCache cache) : base(cache) { @@ -32,7 +34,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Edm var result = Cache.GetOrCreate(cacheKey, entry => { - entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(60); + entry.AbsoluteExpirationRelativeToNow = CacheTime; return BuildEdmModel(schema.SchemaDef, app.PartitionResolver()); }); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs index eb3d9f315..dce94972e 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs @@ -7,13 +7,11 @@ using System; using System.Linq; -using System.Security.Claims; using System.Threading.Tasks; using Microsoft.Extensions.Caching.Memory; using Squidex.Domain.Apps.Entities.Apps; -using Squidex.Domain.Apps.Entities.Assets.Repositories; +using Squidex.Domain.Apps.Entities.Assets; using Squidex.Infrastructure; -using Squidex.Infrastructure.Commands; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { @@ -21,65 +19,88 @@ 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 IAssetQueryService assetQuery; private readonly IAppProvider appProvider; - public CachingGraphQLService(IMemoryCache cache, + public CachingGraphQLService( + IMemoryCache cache, IAppProvider appProvider, - IAssetRepository assetRepository, - ICommandBus commandBus, + IAssetQueryService assetQuery, IContentQueryService contentQuery, IGraphQLUrlGenerator urlGenerator) : base(cache) { Guard.NotNull(appProvider, nameof(appProvider)); - Guard.NotNull(assetRepository, nameof(assetRepository)); - Guard.NotNull(commandBus, nameof(commandBus)); + Guard.NotNull(assetQuery, nameof(assetQuery)); Guard.NotNull(contentQuery, nameof(contentQuery)); Guard.NotNull(urlGenerator, nameof(urlGenerator)); this.appProvider = appProvider; - this.assetRepository = assetRepository; - this.commandBus = commandBus; + this.assetQuery = assetQuery; this.contentQuery = contentQuery; this.urlGenerator = urlGenerator; } - public async Task<(object Data, object[] Errors)> QueryAsync(IAppEntity app, ClaimsPrincipal user, GraphQLQuery query) + public async Task<(bool HasError, object Response)> QueryAsync(QueryContext context, params GraphQLQuery[] queries) { - Guard.NotNull(app, nameof(app)); + Guard.NotNull(context, nameof(context)); + Guard.NotNull(queries, nameof(queries)); + + var model = await GetModelAsync(context.App); + + var ctx = new GraphQLExecutionContext(context, assetQuery, contentQuery, urlGenerator); + + var result = await Task.WhenAll(queries.Select(q => QueryInternalAsync(model, ctx, q))); + + return (result.Any(x => x.HasError), result.Select(x => x.Response).ToArray()); + } + + public async Task<(bool HasError, object Response)> QueryAsync(QueryContext context, GraphQLQuery query) + { + Guard.NotNull(context, nameof(context)); Guard.NotNull(query, nameof(query)); + var model = await GetModelAsync(context.App); + + var ctx = new GraphQLExecutionContext(context, assetQuery, contentQuery, urlGenerator); + + var result = await QueryInternalAsync(model, ctx, query); + + return result; + } + + private static async Task<(bool HasError, object Response)> QueryInternalAsync(GraphQLModel model, GraphQLExecutionContext ctx, GraphQLQuery query) + { if (string.IsNullOrWhiteSpace(query.Query)) { - return (new object(), new object[0]); + return (false, new { data = new object() }); } - var modelContext = await GetModelAsync(app); - - var ctx = new GraphQLExecutionContext(app, assetRepository, commandBus, contentQuery, user, urlGenerator); + var result = await model.ExecuteAsync(ctx, query); - return await modelContext.ExecuteAsync(ctx, query); + if (result.Errors?.Any() == true) + { + return (false, new { data = result.Data, errors = result.Errors }); + } + else + { + return (false, new { data = result.Data }); + } } - private async Task GetModelAsync(IAppEntity app) + private Task GetModelAsync(IAppEntity app) { var cacheKey = CreateCacheKey(app.Id, app.Version.ToString()); - var modelContext = Cache.Get(cacheKey); - - if (modelContext == null) + return Cache.GetOrCreateAsync(cacheKey, async entry => { - var allSchemas = await appProvider.GetSchemasAsync(app.Id); - - modelContext = new GraphQLModel(app, allSchemas.Where(x => x.IsPublished), urlGenerator); + entry.AbsoluteExpirationRelativeToNow = CacheDuration; - Cache.Set(cacheKey, modelContext, CacheDuration); - } + var allSchemas = await appProvider.GetSchemasAsync(app.Id); - return modelContext; + return new GraphQLModel(app, allSchemas, urlGenerator); + }); } private static object CreateCacheKey(Guid appId, string etag) diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs index f73f3ab6d..2c1242877 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs @@ -7,28 +7,21 @@ using System; using System.Collections.Generic; -using System.Security.Claims; using System.Threading.Tasks; 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 GraphQLExecutionContext : QueryContext + public sealed class GraphQLExecutionContext : QueryExecutionContext { - public ICommandBus CommandBus { get; } - public IGraphQLUrlGenerator UrlGenerator { get; } - public GraphQLExecutionContext(IAppEntity app, IAssetRepository assetRepository, ICommandBus commandBus, IContentQueryService contentQuery, ClaimsPrincipal user, + public GraphQLExecutionContext(QueryContext context, + IAssetQueryService assetQueryService, + IContentQueryService contentQuery, IGraphQLUrlGenerator urlGenerator) - : base(app, assetRepository, contentQuery, user) + : base(context, assetQueryService, contentQuery) { - 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 03ceeec54..e35b346dc 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs @@ -13,28 +13,28 @@ using GraphQL; using GraphQL.Resolvers; using GraphQL.Types; using Squidex.Domain.Apps.Core; -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.Contents.GraphQL.Types; +using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Utils; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; using GraphQLSchema = GraphQL.Types.Schema; +#pragma warning disable IDE0003 + namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { public sealed class GraphQLModel : IGraphModel { - private readonly Dictionary> fieldInfos; - private readonly Dictionary inputFieldInfos; private readonly Dictionary contentTypes = new Dictionary(); private readonly Dictionary contentDataTypes = new Dictionary(); - private readonly Dictionary schemas; + private readonly Dictionary schemasById; 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; } @@ -43,96 +43,30 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { this.app = app; - CanGenerateAssetSourceUrl = urlGenerator.CanGenerateAssetSourceUrl; - partitionResolver = app.PartitionResolver(); + CanGenerateAssetSourceUrl = urlGenerator.CanGenerateAssetSourceUrl; + assetType = new AssetGraphType(this); assetListType = new ListGraphType(new NonNullGraphType(assetType)); - inputFieldInfos = new Dictionary - { - { - typeof(StringField), - AllTypes.String - }, - { - typeof(BooleanField), - AllTypes.Boolean - }, - { - typeof(NumberField), - AllTypes.Boolean - }, - { - typeof(DateTimeField), - AllTypes.Date - }, - { - typeof(GeolocationField), - AllTypes.GeolocationInput - }, - { - typeof(TagsField), - AllTypes.ListOfNonNullString - }, - { - typeof(AssetsField), - AllTypes.ListOfNonNullGuid - }, - { - typeof(ReferencesField), - AllTypes.ListOfNonNullGuid - } - }; - - fieldInfos = new Dictionary> - { - { - typeof(StringField), - field => ResolveDefault(AllTypes.NoopString) - }, - { - typeof(BooleanField), - field => ResolveDefault(AllTypes.NoopBoolean) - }, - { - typeof(NumberField), - field => ResolveDefault(AllTypes.NoopFloat) - }, - { - typeof(DateTimeField), - field => ResolveDefault(AllTypes.NoopDate) - }, - { - typeof(JsonField), - field => ResolveDefault(AllTypes.NoopJson) - }, - { - typeof(GeolocationField), - field => ResolveDefault(AllTypes.NoopGeolocation) - }, - { - typeof(TagsField), - field => ResolveDefault(AllTypes.NoopTags) - }, - { - typeof(AssetsField), - field => ResolveAssets(assetListType) - }, - { - typeof(ReferencesField), - field => ResolveReferences(field) - } - }; - - this.schemas = schemas.ToDictionary(x => x.Id); - - var m = new AppMutationsGraphType(this, this.schemas.Values); - var q = new AppQueriesGraphType(this, this.schemas.Values); - - graphQLSchema = new GraphQLSchema { Query = q, Mutation = m }; + schemasById = schemas.Where(x => x.IsPublished).ToDictionary(x => x.Id); + + graphQLSchema = BuildSchema(this); + graphQLSchema.RegisterValueConverter(JsonConverter.Instance); + + InitializeContentTypes(); + } + + private static GraphQLSchema BuildSchema(GraphQLModel model) + { + var schemas = model.schemasById.Values; + + return new GraphQLSchema { Query = new AppQueriesGraphType(model, schemas) }; + } + private void InitializeContentTypes() + { foreach (var kvp in contentDataTypes) { kvp.Value.Initialize(this, kvp.Key); @@ -144,11 +78,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } } - private static (IGraphType ResolveType, IFieldResolver Resolver) ResolveDefault(IGraphType type) - { - return (type, new FuncFieldResolver(c => c.Source.GetOrDefault(c.FieldName))); - } - public IFieldResolver ResolveAssetUrl() { var resolver = new FuncFieldResolver(c => @@ -197,101 +126,63 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL return resolver; } - private static ValueTuple ResolveAssets(IGraphType assetListType) - { - var resolver = new FuncFieldResolver(c => - { - var context = (GraphQLExecutionContext)c.UserContext; - var contentIds = c.Source.GetOrDefault(c.FieldName); - - return context.GetReferencedAssetsAsync(contentIds); - }); - - return (assetListType, resolver); - } - - private ValueTuple ResolveReferences(Field field) - { - var schemaId = ((ReferencesField)field).Properties.SchemaId; - - var contentType = GetContentType(schemaId); - - if (contentType == null) - { - return (null, null); - } - - var resolver = new FuncFieldResolver(c => - { - var context = (GraphQLExecutionContext)c.UserContext; - var contentIds = c.Source.GetOrDefault(c.FieldName); - - return context.GetReferencedContentsAsync(schemaId, contentIds); - }); - - var schemaFieldType = new ListGraphType(new NonNullGraphType(contentType)); - - return (schemaFieldType, resolver); - } - - public async Task<(object Data, object[] Errors)> ExecuteAsync(GraphQLExecutionContext context, GraphQLQuery query) - { - Guard.NotNull(context, nameof(context)); - - var result = await new DocumentExecuter().ExecuteAsync(options => - { - options.Query = query.Query; - options.Schema = graphQLSchema; - options.Inputs = query.Variables?.ToInputs() ?? new Inputs(); - options.UserContext = context; - options.OperationName = query.OperationName; - }).ConfigureAwait(false); - - return (result.Data, result.Errors?.Select(x => (object)new { x.Message, x.Locations }).ToArray()); - } - public IFieldPartitioning ResolvePartition(Partitioning key) { return partitionResolver(key); } - public IComplexGraphType GetAssetType() + public (IGraphType ResolveType, ValueResolver Resolver) GetGraphType(ISchemaEntity schema, IField field) { - return assetType; + return field.Accept(new QueryGraphTypeVisitor(schema, GetContentType, this, assetListType)); } - public (IGraphType ResolveType, IFieldResolver Resolver) GetGraphType(Field field) + public IGraphType GetAssetType() { - return fieldInfos[field.GetType()](field); + return assetType; } - public IComplexGraphType GetContentDataType(Guid schemaId) + public IGraphType GetContentDataType(Guid schemaId) { - var schema = schemas.GetOrDefault(schemaId); + var schema = schemasById.GetOrDefault(schemaId); if (schema == null) { return null; } - return schema != null ? contentDataTypes.GetOrAdd(schema, s => new ContentDataGraphType()) : null; + return contentDataTypes.GetOrAddNew(schema); } - public IComplexGraphType GetContentType(Guid schemaId) + public IGraphType GetContentType(Guid schemaId) { - var schema = schemas.GetOrDefault(schemaId); + var schema = schemasById.GetOrDefault(schemaId); if (schema == null) { return null; } + contentDataTypes.GetOrAdd(schema, s => new ContentDataGraphType()); + return contentTypes.GetOrAdd(schema, s => new ContentGraphType()); } - public IGraphType GetInputGraphType(Field field) + public async Task<(object Data, object[] Errors)> ExecuteAsync(GraphQLExecutionContext context, GraphQLQuery query) { - return inputFieldInfos.GetOrAddDefault(field.GetType()); + Guard.NotNull(context, nameof(context)); + + var inputs = query.Variables?.ToInputs(); + + var result = await new DocumentExecuter().ExecuteAsync(options => + { + options.OperationName = query.OperationName; + options.UserContext = context; + options.Schema = graphQLSchema; + options.Inputs = inputs; + options.Query = query.Query; + }).ConfigureAwait(false); + + return (result.Data, result.Errors?.Select(x => (object)new { x.Message, x.Locations }).ToArray()); } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLQuery.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLQuery.cs index db45e3672..91d17ec15 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLQuery.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLQuery.cs @@ -9,7 +9,7 @@ using Newtonsoft.Json.Linq; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { - public class GraphQLQuery + public sealed class GraphQLQuery { public string OperationName { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphModel.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphModel.cs index 29834fa71..f44c36b58 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphModel.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphModel.cs @@ -10,6 +10,7 @@ using GraphQL.Resolvers; using GraphQL.Types; using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types; using Squidex.Domain.Apps.Entities.Schemas; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL @@ -20,12 +21,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL IFieldPartitioning ResolvePartition(Partitioning key); - IComplexGraphType GetAssetType(); - - IComplexGraphType GetContentType(Guid schemaId); - - IComplexGraphType GetContentDataType(Guid schemaId); - IFieldResolver ResolveAssetUrl(); IFieldResolver ResolveAssetSourceUrl(); @@ -34,8 +29,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL IFieldResolver ResolveContentUrl(ISchemaEntity schema); - IGraphType GetInputGraphType(Field field); + IGraphType GetAssetType(); + + IGraphType GetContentType(Guid schemaId); + + IGraphType GetContentDataType(Guid schemaId); - (IGraphType ResolveType, IFieldResolver Resolver) GetGraphType(Field field); + (IGraphType ResolveType, ValueResolver Resolver) GetGraphType(ISchemaEntity schema, IField field); } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLService.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLService.cs index 6456fd85a..693f1fabf 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLService.cs @@ -5,14 +5,14 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Security.Claims; using System.Threading.Tasks; -using Squidex.Domain.Apps.Entities.Apps; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { public interface IGraphQLService { - Task<(object Data, object[] Errors)> QueryAsync(IAppEntity app, ClaimsPrincipal user, GraphQLQuery query); + Task<(bool HasError, object Response)> QueryAsync(QueryContext context, params GraphQLQuery[] queries); + + Task<(bool HasError, object Response)> QueryAsync(QueryContext context, GraphQLQuery query); } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AllTypes.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AllTypes.cs index dac8e7e07..813d62b93 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AllTypes.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AllTypes.cs @@ -7,6 +7,8 @@ using System; using GraphQL.Types; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Utils; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { @@ -18,50 +20,48 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types public static readonly IGraphType Guid = new GuidGraphType(); - public static readonly IGraphType Date = new DateGraphType(); + public static readonly IGraphType Date = new DateTimeGraphType(); + + public static readonly IGraphType Json = new JsonGraphType(); + + public static readonly IGraphType Tags = new ListGraphType>(); public static readonly IGraphType Float = new FloatGraphType(); + public static readonly IGraphType Status = new EnumerationGraphType(); + public static readonly IGraphType String = new StringGraphType(); public static readonly IGraphType Boolean = new BooleanGraphType(); - public static readonly IGraphType NonNullInt = new NonNullGraphType(new IntGraphType()); + public static readonly IGraphType References = new ListGraphType>(); - public static readonly IGraphType NonNullGuid = new NonNullGraphType(new GuidGraphType()); + public static readonly IGraphType NonNullInt = new NonNullGraphType(Int); - public static readonly IGraphType NonNullDate = new NonNullGraphType(new DateGraphType()); + public static readonly IGraphType NonNullGuid = new NonNullGraphType(Guid); - public static readonly IGraphType NonNullFloat = new NonNullGraphType(new FloatGraphType()); + public static readonly IGraphType NonNullDate = new NonNullGraphType(Date); - public static readonly IGraphType NonNullString = new NonNullGraphType(new StringGraphType()); + public static readonly IGraphType NonNullFloat = new NonNullGraphType(Float); - public static readonly IGraphType NonNullBoolean = new NonNullGraphType(new BooleanGraphType()); + public static readonly IGraphType NonNullString = new NonNullGraphType(String); - public static readonly IGraphType ListOfNonNullGuid = new ListGraphType(new NonNullGraphType(new GuidGraphType())); + public static readonly IGraphType NonNullBoolean = new NonNullGraphType(Boolean); - public static readonly IGraphType ListOfNonNullString = new ListGraphType(new NonNullGraphType(new StringGraphType())); + public static readonly IGraphType NonNullStatusType = new NonNullGraphType(Status); - public static readonly IGraphType NoopInt = new NoopGraphType("Int"); + public static readonly IGraphType NoopDate = new NoopGraphType(Date); - public static readonly IGraphType NoopGuid = new NoopGraphType("Guid"); + public static readonly IGraphType NoopJson = new NoopGraphType(Json); - public static readonly IGraphType NoopDate = new NoopGraphType("Date"); + public static readonly IGraphType NoopFloat = new NoopGraphType(Float); - public static readonly IGraphType NoopJson = new NoopGraphType("Json"); - - public static readonly IGraphType NoopTags = new NoopGraphType("Tags"); + public static readonly IGraphType NoopString = new NoopGraphType(String); - public static readonly IGraphType NoopFloat = new NoopGraphType("Float"); + public static readonly IGraphType NoopBoolean = new NoopGraphType(Boolean); - public static readonly IGraphType NoopString = new NoopGraphType("String"); - - public static readonly IGraphType NoopBoolean = new NoopGraphType("Boolean"); + public static readonly IGraphType NoopTags = new NoopGraphType("Tags"); public static readonly IGraphType NoopGeolocation = new NoopGraphType("Geolocation"); - - public static readonly IGraphType CommandVersion = new CommandVersionGraphType(); - - public static readonly IGraphType GeolocationInput = new GeolocationInputGraphType(); } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppMutationsGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppMutationsGraphType.cs deleted file mode 100644 index 58ec5d018..000000000 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppMutationsGraphType.cs +++ /dev/null @@ -1,344 +0,0 @@ -// ========================================================================== -// 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; -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 resultType = new ContentDataChangedResultGraphType(schemaType, schemaName, contentDataType); - - var inputType = new ContentDataGraphInputType(model, schema); - - AddContentCreate(schemaId, schemaType, schemaName, inputType, contentDataType, contentType); - AddContentUpdate(schemaType, schemaName, inputType, resultType); - AddContentPatch(schemaType, schemaName, inputType, resultType); - AddContentPublish(schemaType, schemaName); - AddContentUnpublish(schemaType, schemaName); - AddContentArchive(schemaType, schemaName); - AddContentRestore(schemaType, schemaName); - AddContentDelete(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(AllTypes.None) - { - Name = "data", - Description = $"The data for the {schemaName} content.", - DefaultValue = null, - ResolvedType = new NonNullGraphType(inputType), - }, - new QueryArgument(AllTypes.None) - { - Name = "publish", - Description = "Set to true to autopublish content.", - DefaultValue = false, - ResolvedType = AllTypes.Boolean - }, - new QueryArgument(AllTypes.None) - { - Name = "expectedVersion", - Description = "The expected version", - DefaultValue = EtagVersion.Any, - ResolvedType = AllTypes.Int - } - }, - ResolvedType = new NonNullGraphType(contentType), - Resolver = ResolveAsync(async (c, publish) => - { - var argPublish = c.GetArgument("publish"); - - var contentData = GetContentData(c); - - var command = new CreateContent { SchemaId = schemaId, Data = contentData, Publish = argPublish }; - var commandContext = await publish(command); - - var result = commandContext.Result>(); - var response = ContentEntity.Create(command, result); - - return (IContentEntity)ContentEntity.Create(command, result); - }), - Description = $"Creates an {schemaName} content." - }); - } - - private void AddContentUpdate(string schemaType, string schemaName, ContentDataGraphInputType inputType, IComplexGraphType resultType) - { - AddField(new FieldType - { - Name = $"update{schemaType}Content", - Arguments = new QueryArguments - { - new QueryArgument(AllTypes.None) - { - Name = "id", - Description = $"The id of the {schemaName} content (GUID)", - DefaultValue = string.Empty, - ResolvedType = AllTypes.NonNullGuid - }, - new QueryArgument(AllTypes.None) - { - Name = "data", - Description = $"The data for the {schemaName} content.", - DefaultValue = null, - ResolvedType = new NonNullGraphType(inputType), - }, - new QueryArgument(AllTypes.None) - { - Name = "expectedVersion", - Description = "The expected version", - DefaultValue = EtagVersion.Any, - ResolvedType = AllTypes.Int - } - }, - ResolvedType = new NonNullGraphType(resultType), - Resolver = ResolveAsync(async (c, publish) => - { - var contentId = c.GetArgument("id"); - var contentData = GetContentData(c); - - var command = new UpdateContent { ContentId = contentId, Data = contentData }; - var commandContext = await publish(command); - - var result = commandContext.Result(); - - return result; - }), - Description = $"Update an {schemaName} content by id." - }); - } - - private void AddContentPatch(string schemaType, string schemaName, ContentDataGraphInputType inputType, IComplexGraphType resultType) - { - AddField(new FieldType - { - Name = $"patch{schemaType}Content", - Arguments = new QueryArguments - { - new QueryArgument(AllTypes.None) - { - Name = "id", - Description = $"The id of the {schemaName} content (GUID)", - DefaultValue = string.Empty, - ResolvedType = AllTypes.NonNullGuid - }, - new QueryArgument(AllTypes.None) - { - Name = "data", - Description = $"The data for the {schemaName} content.", - DefaultValue = null, - ResolvedType = new NonNullGraphType(inputType), - }, - new QueryArgument(AllTypes.None) - { - Name = "expectedVersion", - Description = "The expected version", - DefaultValue = EtagVersion.Any, - ResolvedType = AllTypes.Int - } - }, - ResolvedType = new NonNullGraphType(resultType), - Resolver = ResolveAsync(async (c, publish) => - { - var contentId = c.GetArgument("id"); - var contentData = GetContentData(c); - - var command = new PatchContent { ContentId = contentId, Data = contentData }; - var commandContext = await publish(command); - - var result = commandContext.Result(); - - return result; - }), - Description = $"Patch a {schemaName} content." - }); - } - - private void AddContentPublish(string schemaType, string schemaName) - { - AddField(new FieldType - { - Name = $"publish{schemaType}Content", - Arguments = CreateIdArguments(schemaName), - ResolvedType = AllTypes.CommandVersion, - Resolver = ResolveAsync((c, publish) => - { - var contentId = c.GetArgument("id"); - - var command = new ChangeContentStatus { ContentId = contentId, Status = Status.Published }; - - return publish(command); - }), - Description = $"Publish a {schemaName} content." - }); - } - - private void AddContentUnpublish(string schemaType, string schemaName) - { - AddField(new FieldType - { - Name = $"unpublish{schemaType}Content", - Arguments = CreateIdArguments(schemaName), - ResolvedType = AllTypes.CommandVersion, - Resolver = ResolveAsync((c, publish) => - { - var contentId = c.GetArgument("id"); - - var command = new ChangeContentStatus { ContentId = contentId, Status = Status.Draft }; - - return publish(command); - }), - Description = $"Unpublish a {schemaName} content." - }); - } - - private void AddContentArchive(string schemaType, string schemaName) - { - AddField(new FieldType - { - Name = $"archive{schemaType}Content", - Arguments = CreateIdArguments(schemaName), - ResolvedType = AllTypes.CommandVersion, - Resolver = ResolveAsync((c, publish) => - { - var contentId = c.GetArgument("id"); - - var command = new ChangeContentStatus { ContentId = contentId, Status = Status.Archived }; - - return publish(command); - }), - Description = $"Archive a {schemaName} content." - }); - } - - private void AddContentRestore(string schemaType, string schemaName) - { - AddField(new FieldType - { - Name = $"restore{schemaType}Content", - Arguments = CreateIdArguments(schemaName), - ResolvedType = AllTypes.CommandVersion, - Resolver = ResolveAsync((c, publish) => - { - var contentId = c.GetArgument("id"); - - var command = new ChangeContentStatus { ContentId = contentId, Status = Status.Draft }; - - return publish(command); - }), - Description = $"Restore a {schemaName} content." - }); - } - - private void AddContentDelete(string schemaType, string schemaName) - { - AddField(new FieldType - { - Name = $"delete{schemaType}Content", - Arguments = CreateIdArguments(schemaName), - ResolvedType = AllTypes.CommandVersion, - Resolver = ResolveAsync((c, publish) => - { - var contentId = c.GetArgument("id"); - - var command = new DeleteContent { ContentId = contentId }; - - return publish(command); - }), - Description = $"Delete an {schemaName} content." - }); - } - - private static QueryArguments CreateIdArguments(string schemaName) - { - return new QueryArguments - { - new QueryArgument(AllTypes.None) - { - Name = "id", - Description = $"The id of the {schemaName} content (GUID)", - DefaultValue = string.Empty, - ResolvedType = AllTypes.NonNullGuid - }, - new QueryArgument(AllTypes.None) - { - Name = "expectedVersion", - Description = "The expected version", - DefaultValue = EtagVersion.Any, - ResolvedType = AllTypes.Int - } - }; - } - - private static IFieldResolver ResolveAsync(Func>, Task> action) - { - return new FuncFieldResolver>(async c => - { - var e = (GraphQLExecutionContext)c.UserContext; - - try - { - return await action(c, command => - { - command.ExpectedVersion = c.GetArgument("expectedVersion", EtagVersion.Any); - - return e.CommandBus.PublishAsync(command); - }); - } - catch (ValidationException ex) - { - c.Errors.Add(new ExecutionError(ex.Message)); - - throw; - } - catch (DomainException ex) - { - c.Errors.Add(new ExecutionError(ex.Message)); - - throw; - } - }); - } - - 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 fcc0f6110..3b97da85b 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppQueriesGraphType.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppQueriesGraphType.cs @@ -73,7 +73,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types }); } - private void AddAssetsQueries(IComplexGraphType assetType) + private void AddAssetsQueries(IGraphType assetType) { AddField(new FieldType { @@ -104,7 +104,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types }); } - private void AddContentQueries(Guid schemaId, string schemaType, string schemaName, IComplexGraphType contentType) + private void AddContentQueries(Guid schemaId, string schemaType, string schemaName, IGraphType contentType) { AddField(new FieldType { diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetsResultGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetsResultGraphType.cs index b86685071..04671da20 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetsResultGraphType.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetsResultGraphType.cs @@ -15,16 +15,16 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { public sealed class AssetsResultGraphType : ObjectGraphType> { - public AssetsResultGraphType(IComplexGraphType assetType) + public AssetsResultGraphType(IGraphType assetType) { - Name = $"AssetResultDto"; + Name = "AssetResultDto"; AddField(new FieldType { Name = "total", ResolvedType = AllTypes.Int, Resolver = Resolve(x => x.Total), - Description = $"The total count of assets." + Description = "The total count of assets." }); AddField(new FieldType @@ -32,7 +32,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types Name = "items", Resolver = Resolve(x => x), ResolvedType = new ListGraphType(new NonNullGraphType(assetType)), - Description = $"The assets." + Description = "The assets." }); Description = "List of assets and total count of assets."; diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/CommandVersionGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/CommandVersionGraphType.cs deleted file mode 100644 index 9fdc792f2..000000000 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/CommandVersionGraphType.cs +++ /dev/null @@ -1,44 +0,0 @@ -// ========================================================================== -// 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 : ObjectGraphType - { - public CommandVersionGraphType() - { - Name = "CommandVersionDto"; - - AddField(new FieldType - { - Name = "version", - ResolvedType = AllTypes.Int, - Resolver = ResolveVersion(), - Description = "The new version of the item." - }); - - Description = "The result of a mutation"; - } - - private static IFieldResolver ResolveVersion() - { - 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/ContentDataChangedResultGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataChangedResultGraphType.cs deleted file mode 100644 index ebe00aadb..000000000 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataChangedResultGraphType.cs +++ /dev/null @@ -1,44 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using GraphQL.Resolvers; -using GraphQL.Types; - -namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types -{ - public sealed class ContentDataChangedResultGraphType : ObjectGraphType - { - public ContentDataChangedResultGraphType(string schemaType, string schemaName, IComplexGraphType contentDataType) - { - Name = $"{schemaName}DataChangedResultDto"; - - AddField(new FieldType - { - Name = "version", - ResolvedType = AllTypes.Int, - Resolver = Resolve(x => x.Version), - Description = $"The new version of the {schemaName} content." - }); - - AddField(new FieldType - { - Name = "data", - ResolvedType = new NonNullGraphType(contentDataType), - Resolver = Resolve(x => x.Data), - Description = $"The new data of the {schemaName} content." - }); - - Description = $"The result of the {schemaName} mutation"; - } - - private static IFieldResolver Resolve(Func action) - { - return new FuncFieldResolver(c => action(c.Source)); - } - } -} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphInputType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphInputType.cs deleted file mode 100644 index 42512c861..000000000 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphInputType.cs +++ /dev/null @@ -1,74 +0,0 @@ -// ========================================================================== -// 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 {fieldName} field." - }); - } - } - - 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 d74c6e383..ab748e37a 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphType.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphType.cs @@ -5,9 +5,11 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; using System.Linq; using GraphQL.Resolvers; using GraphQL.Types; +using Newtonsoft.Json.Linq; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; @@ -25,33 +27,49 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types foreach (var field in schema.SchemaDef.Fields.Where(x => !x.IsHidden)) { - var fieldInfo = model.GetGraphType(field); + var fieldInfo = model.GetGraphType(schema, field); if (fieldInfo.ResolveType != null) { - var fieldName = field.RawProperties.Label.WithFallback(field.Name); + var fieldType = field.TypeName(); + var fieldName = field.DisplayName(); var fieldGraphType = new ObjectGraphType { - Name = $"{schemaType}Data{field.Name.ToPascalCase()}Dto" + Name = $"{schemaType}Data{fieldType}Dto" }; var partition = model.ResolvePartition(field.Partitioning); foreach (var partitionItem in partition) { + var resolver = new FuncFieldResolver(c => + { + if (((ContentFieldData)c.Source).TryGetValue(c.FieldName, out var value)) + { + return fieldInfo.Resolver(value, c); + } + else + { + return fieldInfo; + } + }); + fieldGraphType.AddField(new FieldType { Name = partitionItem.Key, - Resolver = fieldInfo.Resolver, + Resolver = resolver, ResolvedType = fieldInfo.ResolveType, Description = field.RawProperties.Hints }); } - fieldGraphType.Description = $"The structure of the {fieldName} of a {schemaName} content type."; + fieldGraphType.Description = $"The structure of the {fieldName} field of the {schemaName} content type."; - var fieldResolver = new FuncFieldResolver(c => c.Source.GetOrDefault(field.Name)); + var fieldResolver = new FuncFieldResolver>(c => + { + return c.Source.GetOrDefault(field.Name); + }); AddField(new FieldType { @@ -63,7 +81,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types } } - Description = $"The structure of a {schemaName} content type."; + Description = $"The structure of the {schemaName} content type."; } } } 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 c23eaeaea..40990b9ad 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs @@ -70,6 +70,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types Description = $"The user that has updated the {schemaName} content last." }); + AddField(new FieldType + { + Name = "status", + ResolvedType = AllTypes.NonNullStatusType, + Resolver = Resolve(x => x.Status), + Description = $"The the status of the {schemaName} content." + }); + AddField(new FieldType { Name = "url", diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentsResultGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentsResultGraphType.cs index c3c052d06..d7ae791e0 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentsResultGraphType.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentsResultGraphType.cs @@ -14,7 +14,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { public sealed class ContentsResultGraphType : ObjectGraphType> { - public ContentsResultGraphType(string schemaType, string schemaName, IComplexGraphType contentType) + public ContentsResultGraphType(string schemaType, string schemaName, IGraphType contentType) { Name = $"{schemaType}ResultDto"; diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/GeolocationInputGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/GeolocationInputGraphType.cs deleted file mode 100644 index 73ba49b5c..000000000 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/GeolocationInputGraphType.cs +++ /dev/null @@ -1,31 +0,0 @@ -// ========================================================================== -// 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 = AllTypes.NonNullFloat - }); - - AddField(new FieldType - { - Name = "longitude", - ResolvedType = AllTypes.NonNullFloat - }); - } - } -} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/GuidGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/GuidGraphType.cs deleted file mode 100644 index 196c8ed44..000000000 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/GuidGraphType.cs +++ /dev/null @@ -1,55 +0,0 @@ -// ========================================================================== -// 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/Contents/GraphQL/Types/NestedGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/NestedGraphType.cs new file mode 100644 index 000000000..b0dc095d2 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/NestedGraphType.cs @@ -0,0 +1,60 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Linq; +using GraphQL.Resolvers; +using GraphQL.Types; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types +{ + public sealed class NestedGraphType : ObjectGraphType + { + public NestedGraphType(IGraphModel model, ISchemaEntity schema, IArrayField field) + { + var schemaType = schema.TypeName(); + var schemaName = schema.DisplayName(); + + var fieldName = field.DisplayName(); + + Name = $"{schemaType}{fieldName}ChildDto"; + + foreach (var nestedField in field.Fields.Where(x => !x.IsHidden)) + { + var fieldInfo = model.GetGraphType(schema, nestedField); + + if (fieldInfo.ResolveType != null) + { + var resolver = new FuncFieldResolver(c => + { + if (((JObject)c.Source).TryGetValue(nestedField.Name, out var value)) + { + return fieldInfo.Resolver(value, c); + } + else + { + return fieldInfo; + } + }); + + AddField(new FieldType + { + Name = nestedField.Name.ToCamelCase(), + Resolver = resolver, + ResolvedType = fieldInfo.ResolveType, + Description = $"The {fieldName}/{nestedField.DisplayName()} nested field." + }); + } + } + + Description = $"The structure of the {schemaName}.{fieldName} nested schema."; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/NoopGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/NoopGraphType.cs deleted file mode 100644 index 401521ea5..000000000 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/NoopGraphType.cs +++ /dev/null @@ -1,36 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// 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 NoopGraphType : ScalarGraphType - { - public NoopGraphType(string name) - { - Name = name; - } - - public override object Serialize(object value) - { - return value; - } - - public override object ParseValue(object value) - { - return value; - } - - public override object ParseLiteral(IValue value) - { - throw new NotSupportedException(); - } - } -} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/QueryGraphTypeVisitor.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/QueryGraphTypeVisitor.cs new file mode 100644 index 000000000..fa1e34514 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/QueryGraphTypeVisitor.cs @@ -0,0 +1,131 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using GraphQL.Types; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Schemas; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types +{ + public delegate object ValueResolver(JToken value, ResolveFieldContext context); + + public sealed class QueryGraphTypeVisitor : IFieldVisitor<(IGraphType ResolveType, ValueResolver Resolver)> + { + private static readonly ValueResolver NoopResolver = (value, c) => value; + private readonly ISchemaEntity schema; + private readonly Func schemaResolver; + private readonly IGraphModel model; + private readonly IGraphType assetListType; + + public QueryGraphTypeVisitor(ISchemaEntity schema, Func schemaResolver, IGraphModel model, IGraphType assetListType) + { + this.model = model; + this.assetListType = assetListType; + this.schema = schema; + this.schemaResolver = schemaResolver; + } + + public (IGraphType ResolveType, ValueResolver Resolver) Visit(IArrayField field) + { + return ResolveNested(field); + } + + public (IGraphType ResolveType, ValueResolver Resolver) Visit(IField field) + { + return ResolveAssets(); + } + + public (IGraphType ResolveType, ValueResolver Resolver) Visit(IField field) + { + return ResolveDefault(AllTypes.NoopBoolean); + } + + public (IGraphType ResolveType, ValueResolver Resolver) Visit(IField field) + { + return ResolveDefault(AllTypes.NoopDate); + } + + public (IGraphType ResolveType, ValueResolver Resolver) Visit(IField field) + { + return ResolveDefault(AllTypes.NoopGeolocation); + } + + public (IGraphType ResolveType, ValueResolver Resolver) Visit(IField field) + { + return ResolveDefault(AllTypes.NoopJson); + } + + public (IGraphType ResolveType, ValueResolver Resolver) Visit(IField field) + { + return ResolveDefault(AllTypes.NoopFloat); + } + + public (IGraphType ResolveType, ValueResolver Resolver) Visit(IField field) + { + return ResolveReferences(field); + } + + public (IGraphType ResolveType, ValueResolver Resolver) Visit(IField field) + { + return ResolveDefault(AllTypes.NoopString); + } + + public (IGraphType ResolveType, ValueResolver Resolver) Visit(IField field) + { + return ResolveDefault(AllTypes.NoopTags); + } + + private static (IGraphType ResolveType, ValueResolver Resolver) ResolveDefault(IGraphType type) + { + return (type, NoopResolver); + } + + private (IGraphType ResolveType, ValueResolver Resolver) ResolveNested(IArrayField field) + { + var schemaFieldType = new ListGraphType(new NonNullGraphType(new NestedGraphType(model, schema, field))); + + return (schemaFieldType, NoopResolver); + } + + private (IGraphType ResolveType, ValueResolver Resolver) ResolveAssets() + { + var resolver = new ValueResolver((value, c) => + { + var context = (GraphQLExecutionContext)c.UserContext; + + return context.GetReferencedAssetsAsync(value); + }); + + return (assetListType, resolver); + } + + private (IGraphType ResolveType, ValueResolver Resolver) ResolveReferences(IField field) + { + var schemaId = ((ReferencesFieldProperties)field.RawProperties).SchemaId; + + var contentType = schemaResolver(schemaId); + + if (contentType == null) + { + return (null, null); + } + + var resolver = new ValueResolver((value, c) => + { + var context = (GraphQLExecutionContext)c.UserContext; + + return context.GetReferencedContentsAsync(schemaId, value); + }); + + var schemaFieldType = new ListGraphType(new NonNullGraphType(contentType)); + + return (schemaFieldType, resolver); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Utils/GuidGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Utils/GuidGraphType.cs new file mode 100644 index 000000000..bbfc8ea5b --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Utils/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.Utils +{ + 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/Contents/GraphQL/Types/Utils/JsonConverter.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Utils/JsonConverter.cs new file mode 100644 index 000000000..62bc939fb --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Utils/JsonConverter.cs @@ -0,0 +1,32 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using GraphQL.Language.AST; +using GraphQL.Types; +using Newtonsoft.Json.Linq; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Utils +{ + public sealed class JsonConverter : IAstFromValueConverter + { + public static readonly JsonConverter Instance = new JsonConverter(); + + private JsonConverter() + { + } + + public IValue Convert(object value, IGraphType type) + { + return new JsonValue(value as JObject); + } + + public bool Matches(object value, IGraphType type) + { + return value is JObject; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Utils/JsonGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Utils/JsonGraphType.cs new file mode 100644 index 000000000..3b99c8412 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Utils/JsonGraphType.cs @@ -0,0 +1,42 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using GraphQL.Language.AST; +using GraphQL.Types; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Utils +{ + public sealed class JsonGraphType : ScalarGraphType + { + public JsonGraphType() + { + Name = "Json"; + + Description = "Unstructured Json object"; + } + + public override object Serialize(object value) + { + return value; + } + + public override object ParseValue(object value) + { + return value; + } + + public override object ParseLiteral(IValue value) + { + if (value is JsonValue jsonGraphType) + { + return jsonGraphType.Value; + } + + return value; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Utils/JsonValue.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Utils/JsonValue.cs new file mode 100644 index 000000000..4977e33d2 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Utils/JsonValue.cs @@ -0,0 +1,25 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using GraphQL.Language.AST; +using Newtonsoft.Json.Linq; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Utils +{ + public sealed class JsonValue : ValueNode + { + public JsonValue(JObject value) + { + Value = value; + } + + protected override bool Equals(ValueNode node) + { + return false; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Utils/NoopGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Utils/NoopGraphType.cs new file mode 100644 index 000000000..8b8c3ceea --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Utils/NoopGraphType.cs @@ -0,0 +1,41 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using GraphQL.Language.AST; +using GraphQL.Types; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Utils +{ + public sealed class NoopGraphType : ScalarGraphType + { + public NoopGraphType(string name) + { + Name = name; + } + + public NoopGraphType(IGraphType type) + : this(type.Name) + { + Description = type.Description; + } + + public override object Serialize(object value) + { + return value; + } + + public override object ParseValue(object value) + { + return value; + } + + public override object ParseLiteral(IValue value) + { + return value.Value; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs b/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs index e7cc66f55..cdb0b309a 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs @@ -8,35 +8,35 @@ using NodaTime; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Contents.Commands; +using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Contents.Guards { public static class GuardContent { - public static void CanCreate(CreateContent command) + public static void CanCreate(ISchemaEntity schema, CreateContent command) { Guard.NotNull(command, nameof(command)); - Validate.It(() => "Cannot created content.", error => + Validate.It(() => "Cannot created content.", e => { - if (command.Data == null) - { - error(new ValidationError("Data cannot be null.", nameof(command.Data))); - } + ValidateData(command, e); }); + + if (schema.IsSingleton && command.ContentId != schema.Id) + { + throw new DomainException("Singleton content cannot be created."); + } } public static void CanUpdate(UpdateContent command) { Guard.NotNull(command, nameof(command)); - Validate.It(() => "Cannot update content.", error => + Validate.It(() => "Cannot update content.", e => { - if (command.Data == null) - { - error(new ValidationError("Data cannot be null.", nameof(command.Data))); - } + ValidateData(command, e); }); } @@ -44,36 +44,75 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards { Guard.NotNull(command, nameof(command)); - Validate.It(() => "Cannot patch content.", error => + Validate.It(() => "Cannot patch content.", e => { - if (command.Data == null) - { - error(new ValidationError("Data cannot be null.", nameof(command.Data))); - } + ValidateData(command, e); }); } - public static void CanChangeContentStatus(Status status, ChangeContentStatus command) + public static void CanDiscardChanges(bool isPending, DiscardChanges command) + { + Guard.NotNull(command, nameof(command)); + + if (!isPending) + { + throw new DomainException("The content has no pending changes."); + } + } + + public static void CanChangeContentStatus(ISchemaEntity schema, bool isPending, Status status, ChangeContentStatus command) { Guard.NotNull(command, nameof(command)); - Validate.It(() => "Cannot change status.", error => + if (schema.IsSingleton && command.Status != Status.Published) + { + throw new DomainException("Singleton content archived or unpublished."); + } + + Validate.It(() => "Cannot change status.", e => { - if (!StatusFlow.Exists(command.Status) || !StatusFlow.CanChange(status, command.Status)) + if (!StatusFlow.Exists(command.Status)) + { + e("Status is not valid.", nameof(command.Status)); + } + else if (!StatusFlow.CanChange(status, command.Status)) { - error(new ValidationError($"Content cannot be changed from status {status} to {command.Status}.", nameof(command.Status))); + if (status == command.Status && status == Status.Published) + { + if (!isPending) + { + e("Content has no changes to publish.", nameof(command.Status)); + } + } + else + { + e($"Cannot change status from {status} to {command.Status}.", nameof(command.Status)); + } } if (command.DueTime.HasValue && command.DueTime.Value < SystemClock.Instance.GetCurrentInstant()) { - error(new ValidationError("DueTime must be in the future.", nameof(command.DueTime))); + e("Due time must be in the future.", nameof(command.DueTime)); } }); } - public static void CanDelete(DeleteContent command) + public static void CanDelete(ISchemaEntity schema, DeleteContent command) { Guard.NotNull(command, nameof(command)); + + if (schema.IsSingleton) + { + throw new DomainException("Singleton content cannot be deleted."); + } + } + + private static void ValidateData(ContentDataCommand command, AddValidation e) + { + if (command.Data == null) + { + e("Data is required.", nameof(command.Data)); + } } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs b/src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs index 11a33154c..1a7e53424 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs @@ -7,7 +7,6 @@ // ========================================================================== using System; -using NodaTime; using Squidex.Domain.Apps.Core.Contents; using Squidex.Infrastructure; @@ -25,12 +24,12 @@ namespace Squidex.Domain.Apps.Entities.Contents Status Status { get; } - Status? ScheduledTo { get; } + ScheduleJob ScheduleJob { get; } - Instant? ScheduledAt { get; } + NamedContentData Data { get; } - RefToken ScheduledBy { get; } + NamedContentData DataDraft { get; } - NamedContentData Data { get; } + bool IsPending { get; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/IContentGrain.cs b/src/Squidex.Domain.Apps.Entities/Contents/IContentGrain.cs new file mode 100644 index 000000000..429a27746 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/IContentGrain.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Orleans; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public interface IContentGrain : IDomainObjectGrain + { + Task> GetStateAsync(long version = EtagVersion.Any); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs b/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs index f14a7d4cd..c2a8c53b1 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs @@ -6,23 +6,17 @@ // ========================================================================== using System; -using System.Collections.Generic; -using System.Security.Claims; using System.Threading.Tasks; -using Squidex.Domain.Apps.Entities.Apps; -using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Contents { public interface IContentQueryService { - Task<(ISchemaEntity Schema, IResultList Contents)> QueryAsync(IAppEntity app, string schemaIdOrName, ClaimsPrincipal user, bool archived, HashSet ids); + Task> QueryAsync(ContentQueryContext context, Q query); - Task<(ISchemaEntity Schema, IResultList Contents)> QueryAsync(IAppEntity app, string schemaIdOrName, ClaimsPrincipal user, bool archived, string query); + Task FindContentAsync(ContentQueryContext context, Guid id, long version = EtagVersion.Any); - Task<(ISchemaEntity Schema, IContentEntity Content)> FindContentAsync(IAppEntity app, string schemaIdOrName, ClaimsPrincipal user, Guid id, long version = EtagVersion.Any); - - Task FindSchemaAsync(IAppEntity app, string schemaIdOrName); + Task ThrowIfSchemaNotExistsAsync(ContentQueryContext context); } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/IContentSchedulerGrain.cs b/src/Squidex.Domain.Apps.Entities/Contents/IContentSchedulerGrain.cs new file mode 100644 index 000000000..77f7fcc0e --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/IContentSchedulerGrain.cs @@ -0,0 +1,15 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.Orleans; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public interface IContentSchedulerGrain : IBackgroundGrain + { + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/IContentVersionLoader.cs b/src/Squidex.Domain.Apps.Entities/Contents/IContentVersionLoader.cs new file mode 100644 index 000000000..e55988d52 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/IContentVersionLoader.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public interface IContentVersionLoader + { + Task LoadAsync(Guid id, long version); + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Queries/FilterTagTransformer.cs b/src/Squidex.Domain.Apps.Entities/Contents/Queries/FilterTagTransformer.cs new file mode 100644 index 000000000..87d319651 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/Queries/FilterTagTransformer.cs @@ -0,0 +1,67 @@ +// ========================================================================== +// 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 Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Core.Tags; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Queries; + +namespace Squidex.Domain.Apps.Entities.Contents.Queries +{ + public sealed class FilterTagTransformer : TransformVisitor + { + private readonly ITagService tagService; + private readonly ISchemaEntity schema; + private readonly Guid appId; + + private FilterTagTransformer(Guid appId, ISchemaEntity schema, ITagService tagService) + { + this.appId = appId; + this.schema = schema; + this.tagService = tagService; + } + + public static FilterNode Transform(FilterNode nodeIn, Guid appId, ISchemaEntity schema, ITagService tagService) + { + Guard.NotNull(tagService, nameof(tagService)); + Guard.NotNull(schema, nameof(schema)); + + return nodeIn.Accept(new FilterTagTransformer(appId, schema, tagService)); + } + + public override FilterNode Visit(FilterComparison nodeIn) + { + if (nodeIn.Rhs.Value is string stringValue && IsDataPath(nodeIn.Lhs) && IsTagField(nodeIn.Lhs)) + { + var tagNames = Task.Run(() => tagService.GetTagIdsAsync(appId, TagGroups.Schemas(schema.Id), HashSet.Of(stringValue))).Result; + + if (tagNames.TryGetValue(stringValue, out var normalized)) + { + return new FilterComparison(nodeIn.Lhs, nodeIn.Operator, new FilterValue(normalized)); + } + } + + return nodeIn; + } + + private static bool IsDataPath(IReadOnlyList path) + { + return path.Count == 3 && string.Equals(path[0], nameof(IContentEntity.Data), StringComparison.OrdinalIgnoreCase); + } + + private bool IsTagField(IReadOnlyList path) + { + return schema.SchemaDef.FieldsByName.TryGetValue(path[1], out var field) && + field is IField fieldTags && + fieldTags.Properties.Normalization == TagsFieldNormalization.Schema; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/QueryContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/QueryContext.cs deleted file mode 100644 index bf5dbd7ab..000000000 --- a/src/Squidex.Domain.Apps.Entities/Contents/QueryContext.cs +++ /dev/null @@ -1,145 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Security.Claims; -using System.Threading.Tasks; -using Squidex.Domain.Apps.Entities.Apps; -using Squidex.Domain.Apps.Entities.Assets; -using Squidex.Domain.Apps.Entities.Assets.Repositories; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Entities.Contents -{ - public class QueryContext - { - private readonly ConcurrentDictionary cachedContents = new ConcurrentDictionary(); - private readonly ConcurrentDictionary cachedAssets = new ConcurrentDictionary(); - private readonly IContentQueryService contentQuery; - private readonly IAssetRepository assetRepository; - private readonly IAppEntity app; - private readonly ClaimsPrincipal user; - - public QueryContext( - IAppEntity app, - IAssetRepository assetRepository, - IContentQueryService contentQuery, - ClaimsPrincipal user) - { - Guard.NotNull(assetRepository, nameof(assetRepository)); - Guard.NotNull(contentQuery, nameof(contentQuery)); - Guard.NotNull(app, nameof(app)); - Guard.NotNull(user, nameof(user)); - - this.assetRepository = assetRepository; - this.contentQuery = contentQuery; - - this.user = user; - - this.app = app; - } - - public async Task FindAssetAsync(Guid id) - { - var asset = cachedAssets.GetOrDefault(id); - - if (asset == null) - { - asset = await assetRepository.FindAssetAsync(id); - - if (asset != null) - { - cachedAssets[asset.Id] = asset; - } - } - - return asset; - } - - public async Task FindContentAsync(Guid schemaId, Guid id) - { - var content = cachedContents.GetOrDefault(id); - - if (content == null) - { - content = (await contentQuery.FindContentAsync(app, schemaId.ToString(), user, id)).Content; - - if (content != null) - { - cachedContents[content.Id] = content; - } - } - - return content; - } - - public async Task> QueryAssetsAsync(string query) - { - var assets = await assetRepository.QueryAsync(app.Id, query); - - foreach (var asset in assets) - { - cachedAssets[asset.Id] = asset; - } - - return assets; - } - - public async Task> QueryContentsAsync(string schemaIdOrName, string query) - { - var result = await contentQuery.QueryAsync(app, schemaIdOrName, user, false, query); - - foreach (var content in result.Contents) - { - cachedContents[content.Id] = content; - } - - return result.Contents; - } - - public async Task> GetReferencedAssetsAsync(ICollection ids) - { - Guard.NotNull(ids, nameof(ids)); - - var notLoadedAssets = new HashSet(ids.Where(id => !cachedAssets.ContainsKey(id))); - - if (notLoadedAssets.Count > 0) - { - var assets = await assetRepository.QueryAsync(app.Id, notLoadedAssets); - - foreach (var asset in assets) - { - cachedAssets[asset.Id] = asset; - } - } - - return ids.Select(cachedAssets.GetOrDefault).Where(x => x != null).ToList(); - } - - public async Task> GetReferencedContentsAsync(Guid schemaId, ICollection ids) - { - Guard.NotNull(ids, nameof(ids)); - - var notLoadedContents = new HashSet(ids.Where(id => !cachedContents.ContainsKey(id))); - - if (notLoadedContents.Count > 0) - { - var result = await contentQuery.QueryAsync(app, schemaId.ToString(), user, false, notLoadedContents); - - foreach (var content in result.Contents) - { - cachedContents[content.Id] = content; - } - } - - return ids.Select(cachedContents.GetOrDefault).Where(x => x != null).ToList(); - } - } -} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs new file mode 100644 index 000000000..43f7d52e4 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs @@ -0,0 +1,133 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public class QueryExecutionContext + { + private readonly ConcurrentDictionary cachedContents = new ConcurrentDictionary(); + private readonly ConcurrentDictionary cachedAssets = new ConcurrentDictionary(); + private readonly IContentQueryService contentQuery; + private readonly IAssetQueryService assetQuery; + private readonly QueryContext context; + + public QueryExecutionContext(QueryContext context, IAssetQueryService assetQuery, IContentQueryService contentQuery) + { + Guard.NotNull(assetQuery, nameof(assetQuery)); + Guard.NotNull(contentQuery, nameof(contentQuery)); + Guard.NotNull(context, nameof(context)); + + this.assetQuery = assetQuery; + this.contentQuery = contentQuery; + this.context = context; + } + + public async Task FindAssetAsync(Guid id) + { + var asset = cachedAssets.GetOrDefault(id); + + if (asset == null) + { + asset = await assetQuery.FindAssetAsync(context, id); + + if (asset != null) + { + cachedAssets[asset.Id] = asset; + } + } + + return asset; + } + + public async Task FindContentAsync(Guid schemaId, Guid id) + { + var content = cachedContents.GetOrDefault(id); + + if (content == null) + { + content = await contentQuery.FindContentAsync(new ContentQueryContext(context).WithSchemaId(schemaId), id); + + if (content != null) + { + cachedContents[content.Id] = content; + } + } + + return content; + } + + public async Task> QueryAssetsAsync(string query) + { + var assets = await assetQuery.QueryAsync(context, Q.Empty.WithODataQuery(query)); + + foreach (var asset in assets) + { + cachedAssets[asset.Id] = asset; + } + + return assets; + } + + public async Task> QueryContentsAsync(string schemaIdOrName, string query) + { + var result = await contentQuery.QueryAsync(new ContentQueryContext(context).WithSchemaName(schemaIdOrName), Q.Empty.WithODataQuery(query)); + + foreach (var content in result) + { + cachedContents[content.Id] = content; + } + + return result; + } + + public async Task> GetReferencedAssetsAsync(ICollection ids) + { + Guard.NotNull(ids, nameof(ids)); + + var notLoadedAssets = new HashSet(ids.Where(id => !cachedAssets.ContainsKey(id))); + + if (notLoadedAssets.Count > 0) + { + var assets = await assetQuery.QueryAsync(context, Q.Empty.WithIds(notLoadedAssets)); + + foreach (var asset in assets) + { + cachedAssets[asset.Id] = asset; + } + } + + return ids.Select(cachedAssets.GetOrDefault).Where(x => x != null).ToList(); + } + + public async Task> GetReferencedContentsAsync(Guid schemaId, ICollection ids) + { + Guard.NotNull(ids, nameof(ids)); + + var notLoadedContents = ids.Where(id => !cachedContents.ContainsKey(id)).ToList(); + + if (notLoadedContents.Count > 0) + { + var result = await contentQuery.QueryAsync(new ContentQueryContext(context).WithSchemaId(schemaId), Q.Empty.WithIds(notLoadedContents)); + + foreach (var content in result) + { + cachedContents[content.Id] = content; + } + } + + return ids.Select(cachedContents.GetOrDefault).Where(x => x != null).ToList(); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs b/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs index b9aba61be..9f65e239b 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs @@ -8,12 +8,12 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; -using Microsoft.OData.UriParser; using NodaTime; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; +using Squidex.Infrastructure.Queries; namespace Squidex.Domain.Apps.Entities.Contents.Repositories { @@ -21,14 +21,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.Repositories { Task> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[] status, HashSet ids); - Task> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[] status, ODataUriParser odataQuery); + Task> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[] status, Query query); Task> QueryNotFoundAsync(Guid appId, Guid schemaId, IList ids); - Task FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id); - - Task FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id, long version); + Task FindContentAsync(IAppEntity app, ISchemaEntity schema, Status[] status, Guid id); Task QueryScheduledWithoutDataAsync(Instant now, Func callback); + + Task RemoveAsync(Guid appId); } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ScheduleJob.cs b/src/Squidex.Domain.Apps.Entities/Contents/ScheduleJob.cs new file mode 100644 index 000000000..d1dc4c444 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/ScheduleJob.cs @@ -0,0 +1,33 @@ +// ========================================================================== +// 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.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public sealed class ScheduleJob + { + public Guid Id { get; } + + public Status Status { get; } + + public RefToken ScheduledBy { get; } + + public Instant DueTime { get; } + + public ScheduleJob(Guid id, Status status, RefToken scheduledBy, Instant dueTime) + { + Id = id; + ScheduledBy = scheduledBy; + Status = status; + DueTime = dueTime; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/SingletonCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Contents/SingletonCommandMiddleware.cs new file mode 100644 index 000000000..71fa52761 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/SingletonCommandMiddleware.cs @@ -0,0 +1,44 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Entities.Contents.Commands; +using Squidex.Domain.Apps.Entities.Schemas.Commands; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public sealed class SingletonCommandMiddleware : ICommandMiddleware + { + public async Task HandleAsync(CommandContext context, Func next) + { + await next(); + + if (context.IsCompleted && + context.Command is CreateSchema createSchema && + createSchema.Singleton) + { + var schemaId = new NamedId(createSchema.SchemaId, createSchema.Name); + + var data = new NamedContentData(); + + var contentId = schemaId.Id; + var content = new CreateContent { Data = data, ContentId = contentId, SchemaId = schemaId }; + + SimpleMapper.Map(createSchema, content); + + content.Publish = true; + + await context.CommandBus.PublishAsync(content); + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs b/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs index 489a03832..fd5b9a343 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs @@ -7,18 +7,17 @@ using System; using Newtonsoft.Json; -using NodaTime; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events.Contents; using Squidex.Infrastructure; using Squidex.Infrastructure.Dispatching; using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Reflection; namespace Squidex.Domain.Apps.Entities.Contents.State { - public class ContentState : DomainObjectState, - IContentEntity + public class ContentState : DomainObjectState, IContentEntity { [JsonProperty] public NamedId AppId { get; set; } @@ -30,48 +29,80 @@ namespace Squidex.Domain.Apps.Entities.Contents.State public NamedContentData Data { get; set; } [JsonProperty] - public Status Status { get; set; } + public NamedContentData DataDraft { get; set; } [JsonProperty] - public Status? ScheduledTo { get; set; } + public ScheduleJob ScheduleJob { get; set; } [JsonProperty] - public Instant? ScheduledAt { get; set; } + public bool IsPending { get; set; } [JsonProperty] - public RefToken ScheduledBy { get; set; } + public bool IsDeleted { get; set; } [JsonProperty] - public bool IsDeleted { get; set; } + public Status Status { get; set; } protected void On(ContentCreated @event) { - SchemaId = @event.SchemaId; + SimpleMapper.Map(@event, this); - Data = @event.Data; - - AppId = @event.AppId; + DataDraft = @event.Data; } protected void On(ContentUpdated @event) { - Data = @event.Data; + DataDraft = @event.Data; + + if (Data != null) + { + Data = @event.Data; + } } - protected void On(ContentStatusScheduled @event) + protected void On(ContentUpdateProposed @event) { - ScheduledAt = @event.DueTime; - ScheduledBy = @event.Actor; - ScheduledTo = @event.Status; + DataDraft = @event.Data; + + IsPending = true; + } + + protected void On(ContentChangesDiscarded @event) + { + DataDraft = Data; + + IsPending = false; + } + + protected void On(ContentChangesPublished @event) + { + ScheduleJob = null; + + Data = DataDraft; + + IsPending = false; } protected void On(ContentStatusChanged @event) { + ScheduleJob = null; + Status = @event.Status; - ScheduledAt = null; - ScheduledBy = null; - ScheduledTo = null; + if (@event.Status == Status.Published) + { + Data = DataDraft; + } + } + + protected void On(ContentSchedulingCancelled @event) + { + ScheduleJob = null; + } + + protected void On(ContentStatusScheduled @event) + { + ScheduleJob = new ScheduleJob(Guid.NewGuid(), @event.Status, @event.Actor, @event.DueTime); } protected void On(ContentDeleted @event) diff --git a/src/Squidex.Domain.Apps.Entities/DomainObjectState.cs b/src/Squidex.Domain.Apps.Entities/DomainObjectState.cs index 210617ff6..13ce32acc 100644 --- a/src/Squidex.Domain.Apps.Entities/DomainObjectState.cs +++ b/src/Squidex.Domain.Apps.Entities/DomainObjectState.cs @@ -40,7 +40,7 @@ namespace Squidex.Domain.Apps.Entities public Instant LastModified { get; set; } [JsonProperty] - public long Version { get; set; } + public long Version { get; set; } = EtagVersion.Empty; public T Clone() { diff --git a/src/Squidex.Domain.Apps.Entities/EdmModelExtensions.cs b/src/Squidex.Domain.Apps.Entities/EdmModelExtensions.cs deleted file mode 100644 index afb38c598..000000000 --- a/src/Squidex.Domain.Apps.Entities/EdmModelExtensions.cs +++ /dev/null @@ -1,38 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Linq; -using Microsoft.OData.Edm; -using Microsoft.OData.UriParser; - -namespace Squidex.Domain.Apps.Entities -{ - public static class EdmModelExtensions - { - public static ODataUriParser ParseQuery(this IEdmModel model, string query) - { - if (!model.EntityContainer.EntitySets().Any()) - { - return null; - } - - query = query ?? string.Empty; - - var path = model.EntityContainer.EntitySets().First().Path.Path.Split('.').Last(); - - if (query.StartsWith("?", StringComparison.Ordinal)) - { - query = query.Substring(1); - } - - var parser = new ODataUriParser(model, new Uri($"{path}?{query}", UriKind.Relative)); - - return parser; - } - } -} diff --git a/src/Squidex.Domain.Apps.Entities/History/Repositories/IHistoryEventRepository.cs b/src/Squidex.Domain.Apps.Entities/History/Repositories/IHistoryEventRepository.cs index 2810f7e18..02a8d6e5a 100644 --- a/src/Squidex.Domain.Apps.Entities/History/Repositories/IHistoryEventRepository.cs +++ b/src/Squidex.Domain.Apps.Entities/History/Repositories/IHistoryEventRepository.cs @@ -14,5 +14,7 @@ namespace Squidex.Domain.Apps.Entities.History.Repositories public interface IHistoryEventRepository { Task> QueryByChannelAsync(Guid appId, string channelPrefix, int count); + + Task RemoveAsync(Guid appId); } } diff --git a/src/Squidex.Domain.Apps.Entities/IEntityWithTags.cs b/src/Squidex.Domain.Apps.Entities/IEntityWithTags.cs new file mode 100644 index 000000000..1049ae23d --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/IEntityWithTags.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; + +namespace Squidex.Domain.Apps.Entities +{ + public interface IEntityWithTags + { + HashSet Tags { get; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Q.cs b/src/Squidex.Domain.Apps.Entities/Q.cs new file mode 100644 index 000000000..8bd9b0f39 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Q.cs @@ -0,0 +1,56 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities +{ + public sealed class Q : Cloneable + { + public static readonly Q Empty = new Q(); + + public IReadOnlyList Ids { get; private set; } + + public string ODataQuery { get; private set; } + + public Q WithODataQuery(string odataQuery) + { + return Clone(c => c.ODataQuery = odataQuery); + } + + public Q WithIds(IEnumerable ids) + { + return Clone(c => c.Ids = ids.ToList()); + } + + public Q WithIds(string ids) + { + if (!string.IsNullOrEmpty(ids)) + { + return Clone(c => + { + var idsList = new List(); + + foreach (var id in ids.Split(',')) + { + if (Guid.TryParse(id, out var guid)) + { + idsList.Add(guid); + } + } + + c.Ids = idsList; + }); + } + + return this; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/QueryContext.cs b/src/Squidex.Domain.Apps.Entities/QueryContext.cs new file mode 100644 index 000000000..c8cd33d2a --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/QueryContext.cs @@ -0,0 +1,82 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Security.Claims; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Security; + +namespace Squidex.Domain.Apps.Entities +{ + public sealed class QueryContext : Cloneable + { + public ClaimsPrincipal User { get; private set; } + + public IAppEntity App { get; private set; } + + public bool Archived { get; private set; } + + public bool Flatten { get; private set; } + + public bool Unpublished { get; set; } + + public IEnumerable Languages { get; private set; } + + private QueryContext() + { + } + + public static QueryContext Create(IAppEntity app, ClaimsPrincipal user) + { + return new QueryContext { App = app, User = user }; + } + + public QueryContext WithUnpublished(bool unpublished) + { + return Clone(c => c.Unpublished = unpublished); + } + + public QueryContext WithArchived(bool archived) + { + return Clone(c => c.Archived = archived); + } + + public QueryContext WithFlatten(bool flatten) + { + return Clone(c => c.Flatten = flatten); + } + + public QueryContext WithLanguages(IEnumerable languageCodes) + { + if (languageCodes != null) + { + return Clone(c => + { + var languages = new List(); + + foreach (var iso2Code in languageCodes) + { + if (Language.TryGetLanguage(iso2Code, out var language)) + { + languages.Add(language); + } + } + + c.Languages = languages; + }); + } + + return this; + } + + public bool IsFrontendClient + { + get { return User.IsInClient("squidex-frontend"); } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/BackupRules.cs b/src/Squidex.Domain.Apps.Entities/Rules/BackupRules.cs new file mode 100644 index 000000000..7ab677449 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/BackupRules.cs @@ -0,0 +1,57 @@ +// ========================================================================== +// 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 Orleans; +using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Domain.Apps.Entities.Rules.Indexes; +using Squidex.Domain.Apps.Entities.Rules.Repositories; +using Squidex.Domain.Apps.Events.Rules; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Domain.Apps.Entities.Rules +{ + public sealed class BackupRules : BackupHandler + { + private readonly HashSet ruleIds = new HashSet(); + private readonly IGrainFactory grainFactory; + + public override string Name { get; } = "Rules"; + + public BackupRules(IGrainFactory grainFactory, IRuleEventRepository ruleEventRepository) + { + Guard.NotNull(grainFactory, nameof(grainFactory)); + Guard.NotNull(ruleEventRepository, nameof(ruleEventRepository)); + + this.grainFactory = grainFactory; + } + + public override Task RestoreEventAsync(Envelope @event, Guid appId, BackupReader reader, RefToken actor) + { + switch (@event.Payload) + { + case RuleCreated ruleCreated: + ruleIds.Add(ruleCreated.RuleId); + break; + case RuleDeleted ruleDeleted: + ruleIds.Remove(ruleDeleted.RuleId); + break; + } + + return TaskHelper.True; + } + + public override async Task RestoreAsync(Guid appId, BackupReader reader) + { + await grainFactory.GetGrain(appId).RebuildAsync(ruleIds); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/EventEnricher.cs b/src/Squidex.Domain.Apps.Entities/Rules/EventEnricher.cs new file mode 100644 index 000000000..c2a126881 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/EventEnricher.cs @@ -0,0 +1,184 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; +using Orleans; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents; +using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Domain.Apps.Entities.Contents; +using Squidex.Domain.Apps.Events; +using Squidex.Domain.Apps.Events.Assets; +using Squidex.Domain.Apps.Events.Contents; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Reflection; +using Squidex.Shared.Users; + +namespace Squidex.Domain.Apps.Entities.Rules +{ + public sealed class EventEnricher : IEventEnricher + { + private static readonly TimeSpan UserCacheDuration = TimeSpan.FromMinutes(10); + private readonly IGrainFactory grainFactory; + private readonly IMemoryCache userCache; + private readonly IUserResolver userResolver; + + public EventEnricher(IGrainFactory grainFactory, IMemoryCache userCache, IUserResolver userResolver) + { + Guard.NotNull(grainFactory, nameof(grainFactory)); + Guard.NotNull(userCache, nameof(userCache)); + Guard.NotNull(userResolver, nameof(userResolver)); + + this.grainFactory = grainFactory; + this.userCache = userCache; + this.userResolver = userResolver; + } + + public async Task EnrichAsync(Envelope @event) + { + Guard.NotNull(@event, nameof(@event)); + + if (@event.Payload is ContentEvent contentEvent) + { + var result = new EnrichedContentEvent(); + + await Task.WhenAll( + EnrichContentAsync(result, contentEvent, @event), + EnrichDefaultAsync(result, @event)); + + return result; + } + + if (@event.Payload is AssetEvent assetEvent) + { + var result = new EnrichedAssetEvent(); + + await Task.WhenAll( + EnrichAssetAsync(result, assetEvent, @event), + EnrichDefaultAsync(result, @event)); + + return result; + } + + return null; + } + + private async Task EnrichAssetAsync(EnrichedAssetEvent result, AssetEvent assetEvent, Envelope @event) + { + var asset = + (await grainFactory + .GetGrain(assetEvent.AssetId) + .GetStateAsync(@event.Headers.EventStreamNumber())).Value; + + SimpleMapper.Map(asset, result); + + switch (assetEvent) + { + case AssetCreated _: + result.Type = EnrichedAssetEventType.Created; + break; + case AssetRenamed _: + result.Type = EnrichedAssetEventType.Renamed; + break; + case AssetUpdated _: + result.Type = EnrichedAssetEventType.Updated; + break; + case AssetDeleted _: + result.Type = EnrichedAssetEventType.Deleted; + break; + } + + result.Name = $"Asset{result.Type}"; + } + + private async Task EnrichContentAsync(EnrichedContentEvent result, ContentEvent contentEvent, Envelope @event) + { + var content = + (await grainFactory + .GetGrain(contentEvent.ContentId) + .GetStateAsync(@event.Headers.EventStreamNumber())).Value; + + SimpleMapper.Map(content, result); + + result.Data = content.Data ?? content.DataDraft; + + switch (contentEvent) + { + case ContentCreated _: + result.Type = EnrichedContentEventType.Created; + break; + case ContentDeleted _: + result.Type = EnrichedContentEventType.Deleted; + break; + case ContentChangesPublished _: + case ContentUpdated _: + result.Type = EnrichedContentEventType.Updated; + break; + case ContentStatusChanged contentStatusChanged: + switch (contentStatusChanged.Change) + { + case StatusChange.Published: + result.Type = EnrichedContentEventType.Published; + break; + case StatusChange.Unpublished: + result.Type = EnrichedContentEventType.Unpublished; + break; + case StatusChange.Archived: + result.Type = EnrichedContentEventType.Archived; + break; + case StatusChange.Restored: + result.Type = EnrichedContentEventType.Restored; + break; + } + + break; + } + + result.Name = $"{content.SchemaId.Name.ToPascalCase()}{result.Type}"; + } + + private async Task EnrichDefaultAsync(EnrichedEvent result, Envelope @event) + { + result.Timestamp = @event.Headers.Timestamp(); + + if (@event.Payload is SquidexEvent squidexEvent) + { + result.Actor = squidexEvent.Actor; + } + + if (@event.Payload is AppEvent appEvent) + { + result.AppId = appEvent.AppId; + } + + result.User = await FindUserAsync(result.Actor); + } + + private Task FindUserAsync(RefToken actor) + { + var key = $"EventEnrichers_Users_${actor.Identifier}"; + + return userCache.GetOrCreateAsync(key, async x => + { + x.AbsoluteExpirationRelativeToNow = UserCacheDuration; + + try + { + return await userResolver.FindByIdOrEmailAsync(actor.Identifier); + } + catch + { + return null; + } + }); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Guards/GuardRule.cs b/src/Squidex.Domain.Apps.Entities/Rules/Guards/GuardRule.cs index 77f1298df..b0dc20d30 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/Guards/GuardRule.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/Guards/GuardRule.cs @@ -19,28 +19,28 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards { Guard.NotNull(command, nameof(command)); - return Validate.It(() => "Cannot create rule.", async error => + return Validate.It(() => "Cannot create rule.", async e => { if (command.Trigger == null) { - error(new ValidationError("Trigger is required.", nameof(command.Trigger))); + e("Trigger is required.", nameof(command.Trigger)); } else { var errors = await RuleTriggerValidator.ValidateAsync(command.AppId.Id, command.Trigger, appProvider); - errors.Foreach(error); + errors.Foreach(x => x.AddTo(e)); } if (command.Action == null) { - error(new ValidationError("Trigger is required.", nameof(command.Action))); + e("Action is required.", nameof(command.Action)); } else { - var errors = await RuleActionValidator.ValidateAsync(command.Action); + var errors = command.Action.Validate(); - errors.Foreach(error); + errors.Foreach(x => x.AddTo(e)); } }); } @@ -49,25 +49,25 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards { Guard.NotNull(command, nameof(command)); - return Validate.It(() => "Cannot update rule.", async error => + return Validate.It(() => "Cannot update rule.", async e => { if (command.Trigger == null && command.Action == null) { - error(new ValidationError("Either trigger or action is required.", nameof(command.Trigger), nameof(command.Action))); + e("Either trigger or action is required.", nameof(command.Trigger), nameof(command.Action)); } if (command.Trigger != null) { var errors = await RuleTriggerValidator.ValidateAsync(appId, command.Trigger, appProvider); - errors.Foreach(error); + errors.Foreach(x => x.AddTo(e)); } if (command.Action != null) { - var errors = await RuleActionValidator.ValidateAsync(command.Action); + var errors = command.Action.Validate(); - errors.Foreach(error); + errors.Foreach(x => x.AddTo(e)); } }); } @@ -76,26 +76,20 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards { Guard.NotNull(command, nameof(command)); - Validate.It(() => "Cannot enable rule.", error => + if (rule.IsEnabled) { - if (rule.IsEnabled) - { - error(new ValidationError("Rule is already enabled.")); - } - }); + throw new DomainException("Rule is already enabled."); + } } public static void CanDisable(DisableRule command, Rule rule) { Guard.NotNull(command, nameof(command)); - Validate.It(() => "Cannot disable rule.", error => + if (!rule.IsEnabled) { - if (!rule.IsEnabled) - { - error(new ValidationError("Rule is already disabled.")); - } - }); + throw new DomainException("Rule is already disabled."); + } } public static void CanDelete(DeleteRule command) diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleActionValidator.cs b/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleActionValidator.cs deleted file mode 100644 index 1a0f522d8..000000000 --- a/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleActionValidator.cs +++ /dev/null @@ -1,134 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Collections.Generic; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using Squidex.Domain.Apps.Core.Rules; -using Squidex.Domain.Apps.Core.Rules.Actions; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Entities.Rules.Guards -{ - public sealed class RuleActionValidator : IRuleActionVisitor>> - { - public static Task> ValidateAsync(RuleAction action) - { - Guard.NotNull(action, nameof(action)); - - var visitor = new RuleActionValidator(); - - return action.Accept(visitor); - } - - public Task> Visit(AlgoliaAction action) - { - var errors = new List(); - - if (string.IsNullOrWhiteSpace(action.ApiKey)) - { - errors.Add(new ValidationError("Api key is required.", nameof(action.ApiKey))); - } - - if (string.IsNullOrWhiteSpace(action.AppId)) - { - errors.Add(new ValidationError("Application ID key is required.", nameof(action.AppId))); - } - - if (string.IsNullOrWhiteSpace(action.IndexName)) - { - errors.Add(new ValidationError("Index name is required.", nameof(action.IndexName))); - } - - return Task.FromResult>(errors); - } - - public Task> Visit(AzureQueueAction action) - { - var errors = new List(); - - if (string.IsNullOrWhiteSpace(action.ConnectionString)) - { - errors.Add(new ValidationError("Connection string is required.", nameof(action.ConnectionString))); - } - - if (string.IsNullOrWhiteSpace(action.Queue)) - { - errors.Add(new ValidationError("Queue is required.", nameof(action.Queue))); - } - else if (!Regex.IsMatch(action.Queue, "^[a-z][a-z0-9]{2,}(\\-[a-z0-9]+)*$")) - { - errors.Add(new ValidationError("Queue must be valid azure queue name.", nameof(action.Queue))); - } - - return Task.FromResult>(errors); - } - - public Task> Visit(ElasticSearchAction action) - { - var errors = new List(); - - if (action.Host == null || !action.Host.IsAbsoluteUri) - { - errors.Add(new ValidationError("Host is required and must be an absolute URL.", nameof(action.Host))); - } - - if (string.IsNullOrWhiteSpace(action.IndexType)) - { - errors.Add(new ValidationError("TypeName is required.", nameof(action.IndexType))); - } - - if (string.IsNullOrWhiteSpace(action.IndexName)) - { - errors.Add(new ValidationError("IndexName is required.", nameof(action.IndexName))); - } - - return Task.FromResult>(errors); - } - - public Task> Visit(FastlyAction action) - { - var errors = new List(); - - if (string.IsNullOrWhiteSpace(action.ApiKey)) - { - errors.Add(new ValidationError("Api key is required.", nameof(action.ApiKey))); - } - - if (string.IsNullOrWhiteSpace(action.ServiceId)) - { - errors.Add(new ValidationError("Service ID is required.", nameof(action.ServiceId))); - } - - return Task.FromResult>(errors); - } - - public Task> Visit(SlackAction action) - { - var errors = new List(); - - if (action.WebhookUrl == null || !action.WebhookUrl.IsAbsoluteUri) - { - errors.Add(new ValidationError("Webhook Url is required and must be an absolute URL.", nameof(action.WebhookUrl))); - } - - return Task.FromResult>(errors); - } - - public Task> Visit(WebhookAction action) - { - var errors = new List(); - - if (action.Url == null || !action.Url.IsAbsoluteUri) - { - errors.Add(new ValidationError("Url is required and must be an absolute URL.", nameof(action.Url))); - } - - return Task.FromResult>(errors); - } - } -} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/IRuleDequeuerGrain.cs b/src/Squidex.Domain.Apps.Entities/Rules/IRuleDequeuerGrain.cs new file mode 100644 index 000000000..793e43617 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/IRuleDequeuerGrain.cs @@ -0,0 +1,15 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.Orleans; + +namespace Squidex.Domain.Apps.Entities.Rules +{ + public interface IRuleDequeuerGrain : IBackgroundGrain + { + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/IRuleGrain.cs b/src/Squidex.Domain.Apps.Entities/Rules/IRuleGrain.cs new file mode 100644 index 000000000..4ba5432c2 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/IRuleGrain.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Orleans; + +namespace Squidex.Domain.Apps.Entities.Rules +{ + public interface IRuleGrain : IDomainObjectGrain + { + Task> GetStateAsync(); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Indexes/IRulesByAppIndex.cs b/src/Squidex.Domain.Apps.Entities/Rules/Indexes/IRulesByAppIndex.cs new file mode 100644 index 000000000..a58689e4c --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/Indexes/IRulesByAppIndex.cs @@ -0,0 +1,27 @@ +// ========================================================================== +// 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 Orleans; + +namespace Squidex.Domain.Apps.Entities.Rules.Indexes +{ + public interface IRulesByAppIndex : IGrainWithGuidKey + { + Task AddRuleAsync(Guid ruleId); + + Task RemoveRuleAsync(Guid ruleId); + + Task RebuildAsync(HashSet rules); + + Task ClearAsync(); + + Task> GetRuleIdsAsync(); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Indexes/RulesByAppIndexCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Rules/Indexes/RulesByAppIndexCommandMiddleware.cs new file mode 100644 index 000000000..faee7b695 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/Indexes/RulesByAppIndexCommandMiddleware.cs @@ -0,0 +1,56 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Orleans; +using Squidex.Domain.Apps.Entities.Rules.Commands; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; + +namespace Squidex.Domain.Apps.Entities.Rules.Indexes +{ + public sealed class RulesByAppIndexCommandMiddleware : ICommandMiddleware + { + private readonly IGrainFactory grainFactory; + + public RulesByAppIndexCommandMiddleware(IGrainFactory grainFactory) + { + Guard.NotNull(grainFactory, nameof(grainFactory)); + + this.grainFactory = grainFactory; + } + + public async Task HandleAsync(CommandContext context, Func next) + { + if (context.IsCompleted) + { + switch (context.Command) + { + case CreateRule createRule: + await Index(createRule.AppId.Id).AddRuleAsync(createRule.RuleId); + break; + case DeleteRule deleteRule: + { + var schema = await grainFactory.GetGrain(deleteRule.RuleId).GetStateAsync(); + + await Index(schema.Value.AppId.Id).RemoveRuleAsync(deleteRule.RuleId); + + break; + } + } + } + + await next(); + } + + private IRulesByAppIndex Index(Guid appId) + { + return grainFactory.GetGrain(appId); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Indexes/RulesByAppIndexGrain.cs b/src/Squidex.Domain.Apps.Entities/Rules/Indexes/RulesByAppIndexGrain.cs new file mode 100644 index 000000000..911833a74 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/Indexes/RulesByAppIndexGrain.cs @@ -0,0 +1,80 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Orleans; +using Squidex.Infrastructure.States; + +namespace Squidex.Domain.Apps.Entities.Rules.Indexes +{ + public sealed class RulesByAppIndexGrain : GrainOfGuid, IRulesByAppIndex + { + private readonly IStore store; + private IPersistence persistence; + private State state = new State(); + + [CollectionName("Index_RulesByApp")] + public sealed class State + { + public HashSet Rules { get; set; } = new HashSet(); + } + + public RulesByAppIndexGrain(IStore store) + { + Guard.NotNull(store, nameof(store)); + + this.store = store; + } + + public override Task OnActivateAsync(Guid key) + { + persistence = store.WithSnapshots(key, s => + { + state = s; + }); + + return persistence.ReadAsync(); + } + + public Task ClearAsync() + { + state = new State(); + + return persistence.DeleteAsync(); + } + + public Task RebuildAsync(HashSet rules) + { + state = new State { Rules = rules }; + + return persistence.WriteSnapshotAsync(state); + } + + public Task AddRuleAsync(Guid ruleId) + { + state.Rules.Add(ruleId); + + return persistence.WriteSnapshotAsync(state); + } + + public Task RemoveRuleAsync(Guid ruleId) + { + state.Rules.Remove(ruleId); + + return persistence.WriteSnapshotAsync(state); + } + + public Task> GetRuleIdsAsync() + { + return Task.FromResult(state.Rules.ToList()); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs b/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs index 31c09131b..37b001f72 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs @@ -25,6 +25,8 @@ namespace Squidex.Domain.Apps.Entities.Rules.Repositories Task QueryPendingAsync(Instant now, Func callback, CancellationToken ct = default(CancellationToken)); + Task RemoveAsync(Guid appId); + Task CountByAppAsync(Guid appId); Task> QueryByAppAsync(Guid appId, int skip = 0, int take = 20); diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleRepository.cs b/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleRepository.cs deleted file mode 100644 index 41cbfde78..000000000 --- a/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleRepository.cs +++ /dev/null @@ -1,18 +0,0 @@ -// ========================================================================== -// 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.Threading.Tasks; - -namespace Squidex.Domain.Apps.Entities.Rules.Repositories -{ - public interface IRuleRepository - { - Task> QueryRuleIdsAsync(Guid appId); - } -} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuer.cs b/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuer.cs deleted file mode 100644 index 693d661bb..000000000 --- a/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuer.cs +++ /dev/null @@ -1,157 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Concurrent; -using System.Threading; -using System.Threading.Tasks; -using System.Threading.Tasks.Dataflow; -using NodaTime; -using Squidex.Domain.Apps.Core.HandleRules; -using Squidex.Domain.Apps.Core.Rules; -using Squidex.Domain.Apps.Entities.Rules.Repositories; -using Squidex.Infrastructure; -using Squidex.Infrastructure.Log; -using Squidex.Infrastructure.Tasks; -using Squidex.Infrastructure.Timers; - -namespace Squidex.Domain.Apps.Entities.Rules -{ - public class RuleDequeuer : DisposableObjectBase, IRunnable - { - private readonly ITargetBlock requestBlock; - private readonly IRuleEventRepository ruleEventRepository; - private readonly RuleService ruleService; - private readonly CompletionTimer timer; - private readonly ConcurrentDictionary executing = new ConcurrentDictionary(); - private readonly IClock clock; - private readonly ISemanticLog log; - - public RuleDequeuer(RuleService ruleService, IRuleEventRepository ruleEventRepository, ISemanticLog log, IClock clock) - { - Guard.NotNull(ruleEventRepository, nameof(ruleEventRepository)); - Guard.NotNull(ruleService, nameof(ruleService)); - Guard.NotNull(clock, nameof(clock)); - Guard.NotNull(log, nameof(log)); - - this.ruleEventRepository = ruleEventRepository; - this.ruleService = ruleService; - - this.clock = clock; - - this.log = log; - - requestBlock = - new PartitionedActionBlock(HandleAsync, x => x.Job.AggregateId.GetHashCode(), - new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = 32, BoundedCapacity = 32 }); - - timer = new CompletionTimer(5000, QueryAsync); - } - - protected override void DisposeObject(bool disposing) - { - if (disposing) - { - timer.StopAsync().Wait(); - - requestBlock.Complete(); - requestBlock.Completion.Wait(); - } - } - - public void Run() - { - } - - public void Next() - { - timer.SkipCurrentDelay(); - } - - private async Task QueryAsync(CancellationToken ct) - { - try - { - var now = clock.GetCurrentInstant(); - - await ruleEventRepository.QueryPendingAsync(now, requestBlock.SendAsync, ct); - } - catch (Exception ex) - { - log.LogError(ex, w => w - .WriteProperty("action", "QueueWebhookEvents") - .WriteProperty("status", "Failed")); - } - } - - public async Task HandleAsync(IRuleEventEntity @event) - { - if (!executing.TryAdd(@event.Id, false)) - { - return; - } - - try - { - var job = @event.Job; - - var response = await ruleService.InvokeAsync(job.ActionName, job.ActionData); - - var jobInvoke = ComputeJobInvoke(response.Result, @event, job); - var jobResult = ComputeJobResult(response.Result, jobInvoke); - - await ruleEventRepository.MarkSentAsync(@event.Id, response.Dump, response.Result, jobResult, response.Elapsed, jobInvoke); - } - catch (Exception ex) - { - log.LogError(ex, w => w - .WriteProperty("action", "SendWebhookEvent") - .WriteProperty("status", "Failed")); - } - finally - { - executing.TryRemove(@event.Id, out var value); - } - } - - private static RuleJobResult ComputeJobResult(RuleResult result, Instant? nextCall) - { - if (result != RuleResult.Success && !nextCall.HasValue) - { - return RuleJobResult.Failed; - } - else if (result != RuleResult.Success && nextCall.HasValue) - { - return RuleJobResult.Retry; - } - else - { - return RuleJobResult.Success; - } - } - - private static Instant? ComputeJobInvoke(RuleResult result, IRuleEventEntity @event, RuleJob job) - { - if (result != RuleResult.Success) - { - switch (@event.NumCalls) - { - case 0: - return job.Created.Plus(Duration.FromMinutes(5)); - case 1: - return job.Created.Plus(Duration.FromHours(1)); - case 2: - return job.Created.Plus(Duration.FromHours(6)); - case 3: - return job.Created.Plus(Duration.FromHours(12)); - } - } - - return null; - } - } -} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuerGrain.cs b/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuerGrain.cs new file mode 100644 index 000000000..7ec68c1ee --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuerGrain.cs @@ -0,0 +1,161 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Concurrent; +using System.Threading.Tasks; +using System.Threading.Tasks.Dataflow; +using NodaTime; +using Orleans; +using Orleans.Runtime; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Domain.Apps.Entities.Rules.Repositories; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Domain.Apps.Entities.Rules +{ + public class RuleDequeuerGrain : Grain, IRuleDequeuerGrain, IRemindable + { + private readonly ITargetBlock requestBlock; + private readonly IRuleEventRepository ruleEventRepository; + private readonly RuleService ruleService; + private readonly ConcurrentDictionary executing = new ConcurrentDictionary(); + private readonly IClock clock; + private readonly ISemanticLog log; + + public RuleDequeuerGrain(RuleService ruleService, IRuleEventRepository ruleEventRepository, ISemanticLog log, IClock clock) + { + Guard.NotNull(ruleEventRepository, nameof(ruleEventRepository)); + Guard.NotNull(ruleService, nameof(ruleService)); + Guard.NotNull(clock, nameof(clock)); + Guard.NotNull(log, nameof(log)); + + this.ruleEventRepository = ruleEventRepository; + this.ruleService = ruleService; + + this.clock = clock; + + this.log = log; + + requestBlock = + new PartitionedActionBlock(HandleAsync, x => x.Job.AggregateId.GetHashCode(), + new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = 32, BoundedCapacity = 32 }); + } + + public override Task OnActivateAsync() + { + DelayDeactivation(TimeSpan.FromDays(1)); + + RegisterOrUpdateReminder("Default", TimeSpan.Zero, TimeSpan.FromMinutes(10)); + RegisterTimer(x => QueryAsync(), null, TimeSpan.Zero, TimeSpan.FromSeconds(10)); + + return Task.FromResult(true); + } + + public override Task OnDeactivateAsync() + { + requestBlock.Complete(); + + return requestBlock.Completion; + } + + public Task ActivateAsync() + { + return TaskHelper.Done; + } + + public async Task QueryAsync() + { + try + { + var now = clock.GetCurrentInstant(); + + await ruleEventRepository.QueryPendingAsync(now, requestBlock.SendAsync); + } + catch (Exception ex) + { + log.LogError(ex, w => w + .WriteProperty("action", "QueueWebhookEvents") + .WriteProperty("status", "Failed")); + } + } + + public async Task HandleAsync(IRuleEventEntity @event) + { + if (!executing.TryAdd(@event.Id, false)) + { + return; + } + + try + { + var job = @event.Job; + + var response = await ruleService.InvokeAsync(job.ActionName, job.ActionData); + + var jobInvoke = ComputeJobInvoke(response.Result, @event, job); + var jobResult = ComputeJobResult(response.Result, jobInvoke); + + await ruleEventRepository.MarkSentAsync(@event.Id, response.Dump, response.Result, jobResult, response.Elapsed, jobInvoke); + } + catch (Exception ex) + { + log.LogError(ex, w => w + .WriteProperty("action", "SendWebhookEvent") + .WriteProperty("status", "Failed")); + } + finally + { + executing.TryRemove(@event.Id, out _); + } + } + + private static RuleJobResult ComputeJobResult(RuleResult result, Instant? nextCall) + { + if (result != RuleResult.Success && !nextCall.HasValue) + { + return RuleJobResult.Failed; + } + else if (result != RuleResult.Success && nextCall.HasValue) + { + return RuleJobResult.Retry; + } + else + { + return RuleJobResult.Success; + } + } + + private static Instant? ComputeJobInvoke(RuleResult result, IRuleEventEntity @event, RuleJob job) + { + if (result != RuleResult.Success) + { + switch (@event.NumCalls) + { + case 0: + return job.Created.Plus(Duration.FromMinutes(5)); + case 1: + return job.Created.Plus(Duration.FromHours(1)); + case 2: + return job.Created.Plus(Duration.FromHours(6)); + case 3: + return job.Created.Plus(Duration.FromHours(12)); + } + } + + return null; + } + + public Task ReceiveReminder(string reminderName, TickStatus status) + { + return TaskHelper.Done; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs b/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs index c962cf109..e1710013e 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs @@ -5,7 +5,10 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; +using System.Collections.Generic; using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Entities.Rules.Repositories; using Squidex.Domain.Apps.Events; @@ -17,8 +20,10 @@ namespace Squidex.Domain.Apps.Entities.Rules { public sealed class RuleEnqueuer : IEventConsumer { + private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(10); private readonly IRuleEventRepository ruleEventRepository; private readonly IAppProvider appProvider; + private readonly IMemoryCache cache; private readonly RuleService ruleService; public string Name @@ -31,15 +36,18 @@ namespace Squidex.Domain.Apps.Entities.Rules get { return ".*"; } } - public RuleEnqueuer(IAppProvider appProvider, IRuleEventRepository ruleEventRepository, + public RuleEnqueuer(IAppProvider appProvider, IMemoryCache cache, IRuleEventRepository ruleEventRepository, RuleService ruleService) { Guard.NotNull(appProvider, nameof(appProvider)); + Guard.NotNull(cache, nameof(cache)); Guard.NotNull(ruleEventRepository, nameof(ruleEventRepository)); Guard.NotNull(ruleService, nameof(ruleService)); this.appProvider = appProvider; + this.cache = cache; + this.ruleEventRepository = ruleEventRepository; this.ruleService = ruleService; } @@ -53,11 +61,11 @@ namespace Squidex.Domain.Apps.Entities.Rules { if (@event.Payload is AppEvent appEvent) { - var rules = await appProvider.GetRulesAsync(appEvent.AppId.Id); + var rules = await GetRulesAsync(appEvent.AppId.Id); foreach (var ruleEntity in rules) { - var job = ruleService.CreateJob(ruleEntity.RuleDef, @event); + var job = await ruleService.CreateJobAsync(ruleEntity.RuleDef, @event); if (job != null) { @@ -66,5 +74,15 @@ namespace Squidex.Domain.Apps.Entities.Rules } } } + + private Task> GetRulesAsync(Guid appId) + { + return cache.GetOrCreateAsync(appId, entry => + { + entry.AbsoluteExpirationRelativeToNow = CacheDuration; + + return appProvider.GetRulesAsync(appId); + }); + } } } \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/Rules/RuleGrain.cs b/src/Squidex.Domain.Apps.Entities/Rules/RuleGrain.cs index e74e2b6ab..92cdd4f95 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/RuleGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/RuleGrain.cs @@ -15,24 +15,26 @@ using Squidex.Domain.Apps.Events.Rules; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.States; namespace Squidex.Domain.Apps.Entities.Rules { - public class RuleGrain : DomainObjectGrain + public sealed class RuleGrain : SquidexDomainObjectGrain, IRuleGrain { private readonly IAppProvider appProvider; - public RuleGrain(IStore store, IAppProvider appProvider) - : base(store) + public RuleGrain(IStore store, ISemanticLog log, IAppProvider appProvider) + : base(store, log) { Guard.NotNull(appProvider, nameof(appProvider)); this.appProvider = appProvider; } - public override Task ExecuteAsync(IAggregateCommand command) + protected override Task ExecuteAsync(IAggregateCommand command) { VerifyNotDeleted(); @@ -121,9 +123,14 @@ namespace Squidex.Domain.Apps.Entities.Rules } } - public override void ApplyEvent(Envelope @event) + protected override RuleState OnEvent(Envelope @event) { - ApplySnapshot(Snapshot.Apply(@event)); + return Snapshot.Apply(@event); + } + + public Task> GetStateAsync() + { + return J.AsTask(Snapshot); } } } diff --git a/src/Squidex.Domain.Apps.Entities/Rules/State/RuleState.cs b/src/Squidex.Domain.Apps.Entities/Rules/State/RuleState.cs index fa87078da..80da5ed16 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/State/RuleState.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/State/RuleState.cs @@ -13,11 +13,12 @@ using Squidex.Domain.Apps.Events.Rules; using Squidex.Infrastructure; using Squidex.Infrastructure.Dispatching; using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.States; namespace Squidex.Domain.Apps.Entities.Rules.State { - public class RuleState : DomainObjectState, - IRuleEntity + [CollectionName("Rules")] + public class RuleState : DomainObjectState, IRuleEntity { [JsonProperty] public NamedId AppId { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/BackupSchemas.cs b/src/Squidex.Domain.Apps.Entities/Schemas/BackupSchemas.cs new file mode 100644 index 000000000..2b2862b6f --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/BackupSchemas.cs @@ -0,0 +1,59 @@ +// ========================================================================== +// 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 Orleans; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Domain.Apps.Entities.Schemas.Indexes; +using Squidex.Domain.Apps.Events.Schemas; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Domain.Apps.Entities.Schemas +{ + public sealed class BackupSchemas : BackupHandler + { + private readonly HashSet> schemaIds = new HashSet>(); + private readonly Dictionary schemasByName = new Dictionary(); + private readonly FieldRegistry fieldRegistry; + private readonly IGrainFactory grainFactory; + + public override string Name { get; } = "Schemas"; + + public BackupSchemas(FieldRegistry fieldRegistry, IGrainFactory grainFactory) + { + Guard.NotNull(fieldRegistry, nameof(fieldRegistry)); + Guard.NotNull(grainFactory, nameof(grainFactory)); + + this.fieldRegistry = fieldRegistry; + + this.grainFactory = grainFactory; + } + + public override Task RestoreEventAsync(Envelope @event, Guid appId, BackupReader reader, RefToken actor) + { + switch (@event.Payload) + { + case SchemaCreated schemaCreated: + schemaIds.Add(schemaCreated.SchemaId); + schemasByName[schemaCreated.SchemaId.Name] = schemaCreated.SchemaId.Id; + break; + } + + return TaskHelper.True; + } + + public override async Task RestoreAsync(Guid appId, BackupReader reader) + { + await grainFactory.GetGrain(appId).RebuildAsync(schemasByName); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/AddField.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/AddField.cs index 8856821d6..09cc26f0a 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/AddField.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/AddField.cs @@ -11,6 +11,8 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Commands { public sealed class AddField : SchemaCommand { + public long? ParentFieldId { get; set; } + public string Name { get; set; } public string Partitioning { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ChangeCategory.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ChangeCategory.cs new file mode 100644 index 000000000..c6eb7ff18 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ChangeCategory.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Schemas.Commands +{ + public sealed class ChangeCategory : SchemaCommand + { + public string Name { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchema.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchema.cs index 923499e79..9c39fa5a9 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchema.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchema.cs @@ -22,6 +22,8 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Commands public SchemaProperties Properties { get; set; } + public bool Singleton { get; set; } + public bool Publish { get; set; } public CreateSchema() diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchemaField.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchemaField.cs index 82e8876eb..6dfba43fc 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchemaField.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchemaField.cs @@ -5,22 +5,14 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Domain.Apps.Core.Schemas; +using System.Collections.Generic; namespace Squidex.Domain.Apps.Entities.Schemas.Commands { - public sealed class CreateSchemaField + public sealed class CreateSchemaField : CreateSchemaFieldBase { - public string Partitioning { get; set; } = Core.Partitioning.Invariant.Key; + public string Partitioning { get; set; } = "invariant"; - public string Name { get; set; } - - public bool IsHidden { get; set; } - - public bool IsLocked { get; set; } - - public bool IsDisabled { get; set; } - - public FieldProperties Properties { get; set; } + public List Nested { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchemaFieldBase.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchemaFieldBase.cs new file mode 100644 index 000000000..3d91afc20 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchemaFieldBase.cs @@ -0,0 +1,24 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Schemas; + +namespace Squidex.Domain.Apps.Entities.Schemas.Commands +{ + public abstract class CreateSchemaFieldBase + { + public string Name { get; set; } + + public bool IsLocked { get; set; } + + public bool IsHidden { get; set; } + + public bool IsDisabled { get; set; } + + public FieldProperties Properties { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchemaNestedField.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchemaNestedField.cs new file mode 100644 index 000000000..55cd4e6eb --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchemaNestedField.cs @@ -0,0 +1,13 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Schemas.Commands +{ + public sealed class CreateSchemaNestedField : CreateSchemaFieldBase + { + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/FieldCommand.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/FieldCommand.cs index 5ad93ddf1..6246ad94c 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/FieldCommand.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/FieldCommand.cs @@ -9,6 +9,8 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Commands { public class FieldCommand : SchemaCommand { + public long? ParentFieldId { get; set; } + public long FieldId { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ReorderFields.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ReorderFields.cs index 9afe0346c..4c452d2ff 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ReorderFields.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ReorderFields.cs @@ -11,6 +11,8 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Commands { public sealed class ReorderFields : SchemaCommand { + public long? ParentFieldId { get; set; } + public List FieldIds { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/SchemaCommand.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/SchemaCommand.cs index 49bba3620..f23581c88 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/SchemaCommand.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/SchemaCommand.cs @@ -8,7 +8,7 @@ using System; using Squidex.Infrastructure.Commands; -namespace Squidex.Domain.Apps.Entities +namespace Squidex.Domain.Apps.Entities.Schemas.Commands { public abstract class SchemaCommand : SquidexCommand, IAggregateCommand { diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/FieldPropertiesValidator.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/FieldPropertiesValidator.cs index e746580f9..8beb2b8f1 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/FieldPropertiesValidator.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/FieldPropertiesValidator.cs @@ -6,7 +6,6 @@ // ========================================================================== using System.Collections.Generic; -using System.Linq; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Infrastructure; @@ -22,7 +21,17 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards public static IEnumerable Validate(FieldProperties properties) { - return properties?.Accept(Instance) ?? Enumerable.Empty(); + return properties?.Accept(Instance); + } + + public IEnumerable Visit(ArrayFieldProperties properties) + { + if (properties.MaxItems.HasValue && properties.MinItems.HasValue && properties.MinItems.Value >= properties.MaxItems.Value) + { + yield return new ValidationError("Max items must be greater than min items.", + nameof(properties.MinItems), + nameof(properties.MaxItems)); + } } public IEnumerable Visit(AssetsFieldProperties properties) diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardHelper.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardHelper.cs new file mode 100644 index 000000000..b0a2b8d5d --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardHelper.cs @@ -0,0 +1,47 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Schemas.Guards +{ + public static class GuardHelper + { + public static IArrayField GetArrayFieldOrThrow(Schema schema, long parentId) + { + if (!schema.FieldsById.TryGetValue(parentId, out var rootField) || !(rootField is IArrayField arrayField)) + { + throw new DomainObjectNotFoundException(parentId.ToString(), "Fields", typeof(Schema)); + } + + return arrayField; + } + + public static IField GetFieldOrThrow(Schema schema, long fieldId, long? parentId) + { + if (parentId.HasValue) + { + var arrayField = GetArrayFieldOrThrow(schema, parentId.Value); + + if (!arrayField.FieldsById.TryGetValue(fieldId, out var nestedField)) + { + throw new DomainObjectNotFoundException(fieldId.ToString(), $"Fields[{parentId}].Fields", typeof(Schema)); + } + + return nestedField; + } + + if (!schema.FieldsById.TryGetValue(fieldId, out var field)) + { + throw new DomainObjectNotFoundException(fieldId.ToString(), "Fields", typeof(Schema)); + } + + return field; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs index fb59048a1..943c3791c 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Squidex.Domain.Apps.Core; @@ -20,52 +21,69 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards { Guard.NotNull(command, nameof(command)); - return Validate.It(() => "Cannot create schema.", async error => + return Validate.It(() => "Cannot create schema.", async e => { if (!command.Name.IsSlug()) { - error(new ValidationError("Name must be a valid slug.", nameof(command.Name))); + e("Name is not a valid slug.", nameof(command.Name)); } - - if (await appProvider.GetSchemaAsync(command.AppId.Id, command.Name) != null) + else if (await appProvider.GetSchemaAsync(command.AppId.Id, command.Name) != null) { - error(new ValidationError($"A schema with name '{command.Name}' already exists", nameof(command.Name))); + e("A schema with the same name already exists."); } - if (command.Fields != null && command.Fields.Any()) + if (command.Fields?.Count > 0) { - var index = 0; + var fieldIndex = 0; + var fieldPrefix = string.Empty; foreach (var field in command.Fields) { - var prefix = $"Fields.{index}"; + fieldIndex++; + fieldPrefix = $"Fields[{fieldIndex}]"; if (!field.Partitioning.IsValidPartitioning()) { - error(new ValidationError("Partitioning is not valid.", $"{prefix}.{nameof(field.Partitioning)}")); + e("Field partitioning is not valid.", $"{fieldPrefix}.{nameof(field.Partitioning)}"); } - if (!field.Name.IsPropertyName()) - { - error(new ValidationError("Name must be a valid property name.", $"{prefix}.{nameof(field.Name)}")); - } + ValidateField(e, fieldPrefix, field); - if (field.Properties == null) + if (field.Nested?.Count > 0) { - error(new ValidationError("Properties is required.", $"{prefix}.{nameof(field.Properties)}")); - } - - var propertyErrors = FieldPropertiesValidator.Validate(field.Properties); - - foreach (var propertyError in propertyErrors) - { - error(propertyError); + if (field.Properties is ArrayFieldProperties) + { + var nestedIndex = 0; + var nestedPrefix = string.Empty; + + foreach (var nestedField in field.Nested) + { + nestedIndex++; + nestedPrefix = $"{fieldPrefix}.Nested[{nestedIndex}]"; + + if (nestedField.Properties is ArrayFieldProperties) + { + e("Nested field cannot be array fields.", $"{nestedPrefix}.{nameof(nestedField.Properties)}"); + } + + ValidateField(e, nestedPrefix, nestedField); + } + } + else if (field.Nested.Count > 0) + { + e("Only array fields can have nested fields.", $"{fieldPrefix}.{nameof(field.Partitioning)}"); + } + + if (field.Nested.Select(x => x.Name).Distinct().Count() != field.Nested.Count) + { + e("Fields cannot have duplicate names.", $"{fieldPrefix}.Nested"); + } } } if (command.Fields.Select(x => x.Name).Distinct().Count() != command.Fields.Count) { - error(new ValidationError("Fields cannot have duplicate names.", nameof(command.Fields))); + e("Fields cannot have duplicate names.", nameof(command.Fields)); } } }); @@ -75,16 +93,27 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards { Guard.NotNull(command, nameof(command)); + IArrayField arrayField = null; + + if (command.ParentFieldId.HasValue) + { + arrayField = GuardHelper.GetArrayFieldOrThrow(schema, command.ParentFieldId.Value); + } + Validate.It(() => "Cannot reorder schema fields.", error => { if (command.FieldIds == null) { - error(new ValidationError("Field ids is required.", nameof(command.FieldIds))); + error("Field ids is required.", nameof(command.FieldIds)); } - if (command.FieldIds != null && (command.FieldIds.Count != schema.Fields.Count || command.FieldIds.Any(x => !schema.FieldsById.ContainsKey(x)))) + if (arrayField == null) + { + ValidateFieldIds(error, command, schema.FieldsById); + } + else { - error(new ValidationError("Ids must cover all fields.", nameof(command.FieldIds))); + ValidateFieldIds(error, command, arrayField.FieldsById); } }); } @@ -119,9 +148,41 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards Guard.NotNull(command, nameof(command)); } + public static void CanChangeCategory(Schema schema, ChangeCategory command) + { + Guard.NotNull(command, nameof(command)); + } + public static void CanDelete(Schema schema, DeleteSchema command) { Guard.NotNull(command, nameof(command)); } + + private static void ValidateField(AddValidation e, string prefix, CreateSchemaFieldBase field) + { + if (!field.Name.IsPropertyName()) + { + e("Field name must be a valid javascript property name.", $"{prefix}.{nameof(field.Name)}"); + } + + if (field.Properties == null) + { + e("Field properties is required.", $"{prefix}.{nameof(field.Properties)}"); + } + else + { + var errors = FieldPropertiesValidator.Validate(field.Properties); + + errors.Foreach(x => x.WithPrefix($"{prefix}.{nameof(field.Properties)}").AddTo(e)); + } + } + + private static void ValidateFieldIds(AddValidation error, ReorderFields c, IReadOnlyDictionary fields) + { + if (c.FieldIds != null && (c.FieldIds.Count != fields.Count || c.FieldIds.Any(x => !fields.ContainsKey(x)))) + { + error("Field ids do not cover all fields.", nameof(c.FieldIds)); + } + } } } diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchemaField.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchemaField.cs index 73463a710..7b7f6a313 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchemaField.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchemaField.cs @@ -18,33 +18,44 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards { Guard.NotNull(command, nameof(command)); - Validate.It(() => "Cannot add a new field.", error => + Validate.It(() => "Cannot add a new field.", e => { - if (!command.Partitioning.IsValidPartitioning()) - { - error(new ValidationError("Partitioning is not valid.", nameof(command.Partitioning))); - } - if (!command.Name.IsPropertyName()) { - error(new ValidationError("Name must be a valid property name.", nameof(command.Name))); + e("Name must be a valid javascript property name.", nameof(command.Name)); } if (command.Properties == null) { - error(new ValidationError("Properties is required.", nameof(command.Properties))); + e("Properties is required.", nameof(command.Properties)); } + else + { + var errors = FieldPropertiesValidator.Validate(command.Properties); - var propertyErrors = FieldPropertiesValidator.Validate(command.Properties); + errors.Foreach(x => x.WithPrefix(nameof(command.Properties)).AddTo(e)); + } - foreach (var propertyError in propertyErrors) + if (command.ParentFieldId.HasValue) { - error(propertyError); - } + var arrayField = GuardHelper.GetArrayFieldOrThrow(schema, command.ParentFieldId.Value); - if (schema.FieldsByName.ContainsKey(command.Name)) + if (arrayField.FieldsByName.ContainsKey(command.Name)) + { + e("A field with the same name already exists."); + } + } + else { - error(new ValidationError($"There is already a field with name '{command.Name}'", nameof(command.Name))); + if (command.ParentFieldId == null && !command.Partitioning.IsValidPartitioning()) + { + e("Partitioning is not valid.", nameof(command.Partitioning)); + } + + if (schema.FieldsByName.ContainsKey(command.Name)) + { + e("A field with the same name already exists."); + } } }); } @@ -53,80 +64,86 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards { Guard.NotNull(command, nameof(command)); - Validate.It(() => "Cannot update field.", error => + var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId); + + if (field.IsLocked) + { + throw new DomainException("Schema field is already locked."); + } + + Validate.It(() => "Cannot update field.", e => { if (command.Properties == null) { - error(new ValidationError("Properties is required.", nameof(command.Properties))); + e("Properties is required.", nameof(command.Properties)); } - - var propertyErrors = FieldPropertiesValidator.Validate(command.Properties); - - foreach (var propertyError in propertyErrors) + else { - error(propertyError); + var errors = FieldPropertiesValidator.Validate(command.Properties); + + errors.Foreach(x => x.WithPrefix(nameof(command.Properties)).AddTo(e)); } }); - - var field = GetFieldOrThrow(schema, command.FieldId); - - if (field.IsLocked) - { - throw new DomainException("Schema field is already locked."); - } } - public static void CanDelete(Schema schema, DeleteField command) + public static void CanHide(Schema schema, HideField command) { Guard.NotNull(command, nameof(command)); - var field = GetFieldOrThrow(schema, command.FieldId); + var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId); if (field.IsLocked) { throw new DomainException("Schema field is locked."); } + + if (field.IsHidden) + { + throw new DomainException("Schema field is already hidden."); + } } - public static void CanHide(Schema schema, HideField command) + public static void CanDisable(Schema schema, DisableField command) { Guard.NotNull(command, nameof(command)); - var field = GetFieldOrThrow(schema, command.FieldId); + var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId); - if (field.IsHidden) + if (field.IsDisabled) { - throw new DomainException("Schema field is already hidden."); + throw new DomainException("Schema field is already disabled."); } } - public static void CanShow(Schema schema, ShowField command) + public static void CanDelete(Schema schema, DeleteField command) { Guard.NotNull(command, nameof(command)); - var field = GetFieldOrThrow(schema, command.FieldId); + var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId); - if (!field.IsHidden) + if (field.IsLocked) { - throw new DomainException("Schema field is already visible."); + throw new DomainException("Schema field is locked."); } } - public static void CanDisable(Schema schema, DisableField command) + public static void CanShow(Schema schema, ShowField command) { Guard.NotNull(command, nameof(command)); - var field = GetFieldOrThrow(schema, command.FieldId); + var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId); - if (field.IsDisabled) + if (!field.IsHidden) { - throw new DomainException("Schema field is already disabled."); + throw new DomainException("Schema field is already visible."); } } public static void CanEnable(Schema schema, EnableField command) { - var field = GetFieldOrThrow(schema, command.FieldId); + Guard.NotNull(command, nameof(command)); + + var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId); if (!field.IsDisabled) { @@ -138,22 +155,12 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards { Guard.NotNull(command, nameof(command)); - var field = GetFieldOrThrow(schema, command.FieldId); + var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId); if (field.IsLocked) { throw new DomainException("Schema field is already locked."); } } - - private static Field GetFieldOrThrow(Schema schema, long fieldId) - { - if (!schema.FieldsById.TryGetValue(fieldId, out var field)) - { - throw new DomainObjectNotFoundException(fieldId.ToString(), "Fields", typeof(Schema)); - } - - return field; - } } } diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/ISchemaEntity.cs b/src/Squidex.Domain.Apps.Entities/Schemas/ISchemaEntity.cs index 8c341e76e..98913f9e3 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/ISchemaEntity.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/ISchemaEntity.cs @@ -21,6 +21,10 @@ namespace Squidex.Domain.Apps.Entities.Schemas string Name { get; } + string Category { get; } + + bool IsSingleton { get; } + bool IsPublished { get; } bool IsDeleted { get; } diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/ISchemaGrain.cs b/src/Squidex.Domain.Apps.Entities/Schemas/ISchemaGrain.cs new file mode 100644 index 000000000..ab41ae13e --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/ISchemaGrain.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Orleans; + +namespace Squidex.Domain.Apps.Entities.Schemas +{ + public interface ISchemaGrain : IDomainObjectGrain + { + Task> GetStateAsync(); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/ISchemasByAppIndex.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/ISchemasByAppIndex.cs new file mode 100644 index 000000000..0cffd11a9 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/ISchemasByAppIndex.cs @@ -0,0 +1,29 @@ +// ========================================================================== +// 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 Orleans; + +namespace Squidex.Domain.Apps.Entities.Schemas.Indexes +{ + public interface ISchemasByAppIndex : IGrainWithGuidKey + { + Task AddSchemaAsync(Guid schemaId, string name); + + Task RemoveSchemaAsync(Guid schemaId); + + Task RebuildAsync(Dictionary schemas); + + Task ClearAsync(); + + Task GetSchemaIdAsync(string name); + + Task> GetSchemaIdsAsync(); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/SchemasByAppIndexCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/SchemasByAppIndexCommandMiddleware.cs new file mode 100644 index 000000000..1090894d5 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/SchemasByAppIndexCommandMiddleware.cs @@ -0,0 +1,56 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Orleans; +using Squidex.Domain.Apps.Entities.Schemas.Commands; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; + +namespace Squidex.Domain.Apps.Entities.Schemas.Indexes +{ + public sealed class SchemasByAppIndexCommandMiddleware : ICommandMiddleware + { + private readonly IGrainFactory grainFactory; + + public SchemasByAppIndexCommandMiddleware(IGrainFactory grainFactory) + { + Guard.NotNull(grainFactory, nameof(grainFactory)); + + this.grainFactory = grainFactory; + } + + public async Task HandleAsync(CommandContext context, Func next) + { + if (context.IsCompleted) + { + switch (context.Command) + { + case CreateSchema createSchema: + await Index(createSchema.AppId.Id).AddSchemaAsync(createSchema.SchemaId, createSchema.Name); + break; + case DeleteSchema deleteSchema: + { + var schema = await grainFactory.GetGrain(deleteSchema.SchemaId).GetStateAsync(); + + await Index(schema.Value.AppId.Id).RemoveSchemaAsync(deleteSchema.SchemaId); + + break; + } + } + } + + await next(); + } + + private ISchemasByAppIndex Index(Guid appId) + { + return grainFactory.GetGrain(appId); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/SchemasByAppIndexGrain.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/SchemasByAppIndexGrain.cs new file mode 100644 index 000000000..69eff9348 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/SchemasByAppIndexGrain.cs @@ -0,0 +1,87 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Orleans; +using Squidex.Infrastructure.States; + +namespace Squidex.Domain.Apps.Entities.Schemas.Indexes +{ + public sealed class SchemasByAppIndexGrain : GrainOfGuid, ISchemasByAppIndex + { + private readonly IStore store; + private IPersistence persistence; + private State state = new State(); + + [CollectionName("Index_SchemasByApp")] + public sealed class State + { + public Dictionary Schemas { get; set; } = new Dictionary(); + } + + public SchemasByAppIndexGrain(IStore store) + { + Guard.NotNull(store, nameof(store)); + + this.store = store; + } + + public override Task OnActivateAsync(Guid key) + { + persistence = store.WithSnapshots(key, s => + { + state = s; + }); + + return persistence.ReadAsync(); + } + + public Task ClearAsync() + { + state = new State(); + + return persistence.DeleteAsync(); + } + + public Task RebuildAsync(Dictionary schemas) + { + state = new State { Schemas = schemas }; + + return persistence.WriteSnapshotAsync(state); + } + + public Task AddSchemaAsync(Guid schemaId, string name) + { + state.Schemas[name] = schemaId; + + return persistence.WriteSnapshotAsync(state); + } + + public Task RemoveSchemaAsync(Guid schemaId) + { + state.Schemas.Remove(state.Schemas.FirstOrDefault(x => x.Value == schemaId).Key ?? string.Empty); + + return persistence.WriteSnapshotAsync(state); + } + + public Task GetSchemaIdAsync(string name) + { + state.Schemas.TryGetValue(name, out var schemaId); + + return Task.FromResult(schemaId); + } + + public Task> GetSchemaIdsAsync() + { + return Task.FromResult(state.Schemas.Values.ToList()); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Repositories/ISchemaRepository.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Repositories/ISchemaRepository.cs deleted file mode 100644 index 53294cef3..000000000 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Repositories/ISchemaRepository.cs +++ /dev/null @@ -1,20 +0,0 @@ -// ========================================================================== -// 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.Threading.Tasks; - -namespace Squidex.Domain.Apps.Entities.Schemas.Repositories -{ - public interface ISchemaRepository - { - Task FindSchemaIdAsync(Guid appId, string name); - - Task> QuerySchemaIdsAsync(Guid appId); - } -} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaExtensions.cs b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaExtensions.cs index 5d86e5682..4d45ac893 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaExtensions.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaExtensions.cs @@ -18,11 +18,21 @@ namespace Squidex.Domain.Apps.Entities.Schemas return new NamedId(schema.Id, schema.Name); } + public static string TypeName(this IField field) + { + return field.Name.ToPascalCase(); + } + public static string TypeName(this ISchemaEntity schema) { return schema.SchemaDef.Name.ToPascalCase(); } + public static string DisplayName(this IField field) + { + return field.RawProperties.Label.WithFallback(field.TypeName()); + } + public static string DisplayName(this ISchemaEntity schema) { return schema.SchemaDef.Properties.Label.WithFallback(schema.TypeName()); diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs index 2f12f1336..9ffda0930 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs @@ -7,7 +7,6 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Entities.Schemas.Commands; @@ -18,18 +17,20 @@ using Squidex.Domain.Apps.Events.Schemas; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.States; namespace Squidex.Domain.Apps.Entities.Schemas { - public class SchemaGrain : DomainObjectGrain + public sealed class SchemaGrain : SquidexDomainObjectGrain, ISchemaGrain { private readonly IAppProvider appProvider; private readonly FieldRegistry registry; - public SchemaGrain(IStore store, IAppProvider appProvider, FieldRegistry registry) - : base(store) + public SchemaGrain(IStore store, ISemanticLog log, IAppProvider appProvider, FieldRegistry registry) + : base(store, log) { Guard.NotNull(appProvider, nameof(appProvider)); Guard.NotNull(registry, nameof(registry)); @@ -39,28 +40,39 @@ namespace Squidex.Domain.Apps.Entities.Schemas this.registry = registry; } - public override Task ExecuteAsync(IAggregateCommand command) + protected override Task ExecuteAsync(IAggregateCommand command) { VerifyNotDeleted(); switch (command) { - case CreateSchema createSchema: - return CreateAsync(createSchema, async c => - { - await GuardSchema.CanCreate(c, appProvider); - - Create(c); - }); - case AddField addField: - return UpdateReturnAsync(addField, c => + return UpdateAsync(addField, c => { GuardSchemaField.CanAdd(Snapshot.SchemaDef, c); Add(c); - return EntityCreatedResult.Create(Snapshot.SchemaDef.FieldsById.Values.First(x => x.Name == addField.Name).Id, NewVersion); + long id; + + if (c.ParentFieldId == null) + { + id = Snapshot.SchemaDef.FieldsByName[c.Name].Id; + } + else + { + id = ((IArrayField)Snapshot.SchemaDef.FieldsById[c.ParentFieldId.Value]).FieldsByName[c.Name].Id; + } + + return EntityCreatedResult.Create(id, Version); + }); + + case CreateSchema createSchema: + return CreateAsync(createSchema, async c => + { + await GuardSchema.CanCreate(c, appProvider); + + Create(c); }); case DeleteField deleteField: @@ -159,6 +171,14 @@ namespace Squidex.Domain.Apps.Entities.Schemas ConfigureScripts(c); }); + case ChangeCategory changeCategory: + return UpdateAsync(changeCategory, c => + { + GuardSchema.CanChangeCategory(Snapshot.SchemaDef, c); + + ChangeCategory(c); + }); + case DeleteSchema deleteSchema: return UpdateAsync(deleteSchema, c => { @@ -174,7 +194,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas public void Create(CreateSchema command) { - var @event = SimpleMapper.Map(command, new SchemaCreated { SchemaId = new NamedId(command.SchemaId, command.Name) }); + var @event = SimpleMapper.Map(command, new SchemaCreated { SchemaId = NamedId.Of(command.SchemaId, command.Name) }); if (command.Fields != null) { @@ -185,6 +205,18 @@ namespace Squidex.Domain.Apps.Entities.Schemas var eventField = SimpleMapper.Map(commandField, new SchemaCreatedField()); @event.Fields.Add(eventField); + + if (commandField.Nested != null) + { + eventField.Nested = new List(); + + foreach (var nestedField in commandField.Nested) + { + var eventNestedField = SimpleMapper.Map(nestedField, new SchemaCreatedNestedField()); + + eventField.Nested.Add(eventNestedField); + } + } } } @@ -193,7 +225,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas public void Add(AddField command) { - RaiseEvent(SimpleMapper.Map(command, new FieldAdded { FieldId = new NamedId(Snapshot.TotalFields + 1, command.Name) })); + RaiseEvent(SimpleMapper.Map(command, new FieldAdded { ParentFieldId = GetFieldId(command.ParentFieldId), FieldId = CreateFieldId(command) })); } public void UpdateField(UpdateField command) @@ -233,7 +265,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas public void Reorder(ReorderFields command) { - RaiseEvent(SimpleMapper.Map(command, new SchemaFieldsReordered())); + RaiseEvent(SimpleMapper.Map(command, new SchemaFieldsReordered { ParentFieldId = GetFieldId(command.ParentFieldId) })); } public void Publish(PublishSchema command) @@ -251,6 +283,11 @@ namespace Squidex.Domain.Apps.Entities.Schemas RaiseEvent(SimpleMapper.Map(command, new ScriptsConfigured())); } + public void ChangeCategory(ChangeCategory command) + { + RaiseEvent(SimpleMapper.Map(command, new SchemaCategoryChanged())); + } + public void Delete(DeleteSchema command) { RaiseEvent(SimpleMapper.Map(command, new SchemaDeleted())); @@ -265,19 +302,46 @@ namespace Squidex.Domain.Apps.Entities.Schemas { SimpleMapper.Map(fieldCommand, @event); - if (Snapshot.SchemaDef.FieldsById.TryGetValue(fieldCommand.FieldId, out var field)) + if (fieldCommand.ParentFieldId.HasValue) + { + if (Snapshot.SchemaDef.FieldsById.TryGetValue(fieldCommand.ParentFieldId.Value, out var field)) + { + @event.ParentFieldId = NamedId.Of(field.Id, field.Name); + + if (field is IArrayField arrayField && arrayField.FieldsById.TryGetValue(fieldCommand.FieldId, out var nestedField)) + { + @event.FieldId = NamedId.Of(nestedField.Id, nestedField.Name); + } + } + } + else { - @event.FieldId = new NamedId(field.Id, field.Name); + @event.FieldId = GetFieldId(fieldCommand.FieldId); } RaiseEvent(@event); } + private NamedId CreateFieldId(AddField command) + { + return NamedId.Of(Snapshot.TotalFields + 1L, command.Name); + } + + private NamedId GetFieldId(long? id) + { + if (id.HasValue && Snapshot.SchemaDef.FieldsById.TryGetValue(id.Value, out var field)) + { + return NamedId.Of(field.Id, field.Name); + } + + return null; + } + private void RaiseEvent(SchemaEvent @event) { if (@event.SchemaId == null) { - @event.SchemaId = new NamedId(Snapshot.Id, Snapshot.Name); + @event.SchemaId = NamedId.Of(Snapshot.Id, Snapshot.Name); } if (@event.AppId == null) @@ -296,9 +360,14 @@ namespace Squidex.Domain.Apps.Entities.Schemas } } - public override void ApplyEvent(Envelope @event) + protected override SchemaState OnEvent(Envelope @event) + { + return Snapshot.Apply(@event, registry); + } + + public Task> GetStateAsync() { - ApplySnapshot(Snapshot.Apply(@event, registry)); + return J.AsTask(Snapshot); } } } \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaHistoryEventsCreator.cs b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaHistoryEventsCreator.cs index 0eb3c7562..3990c07b1 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaHistoryEventsCreator.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaHistoryEventsCreator.cs @@ -20,49 +20,49 @@ namespace Squidex.Domain.Apps.Entities.Schemas : base(typeNameRegistry) { AddEventMessage( - "created schema {[Name]}"); + "created schema {[Name]}."); AddEventMessage( - "updated schema {[Name]}"); + "updated schema {[Name]}."); AddEventMessage( - "deleted schema {[Name]}"); + "deleted schema {[Name]}."); AddEventMessage( - "published schema {[Name]}"); + "published schema {[Name]}."); AddEventMessage( - "unpublished schema {[Name]}"); + "unpublished schema {[Name]}."); AddEventMessage( - "reordered fields of schema {[Name]}"); + "reordered fields of schema {[Name]}."); AddEventMessage( - "added field {[Field]} to schema {[Name]}"); + "added field {[Field]} to schema {[Name]}."); AddEventMessage( - "deleted field {[Field]} from schema {[Name]}"); + "deleted field {[Field]} from schema {[Name]}."); AddEventMessage( - "has locked field {[Field]} of schema {[Name]}"); + "has locked field {[Field]} of schema {[Name]}."); AddEventMessage( - "has hidden field {[Field]} of schema {[Name]}"); + "has hidden field {[Field]} of schema {[Name]}."); AddEventMessage( - "has shown field {[Field]} of schema {[Name]}"); + "has shown field {[Field]} of schema {[Name]}."); AddEventMessage( - "disabled field {[Field]} of schema {[Name]}"); + "disabled field {[Field]} of schema {[Name]}."); AddEventMessage( - "disabled field {[Field]} of schema {[Name]}"); + "disabled field {[Field]} of schema {[Name]}."); AddEventMessage( - "has updated field {[Field]} of schema {[Name]}"); + "has updated field {[Field]} of schema {[Name]}."); AddEventMessage( - "deleted field {[Field]} of schema {[Name]}"); + "deleted field {[Field]} of schema {[Name]}."); } protected override Task CreateEventCoreAsync(Envelope @event) diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/State/SchemaState.cs b/src/Squidex.Domain.Apps.Entities/Schemas/State/SchemaState.cs index bc148430b..fdec25995 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/State/SchemaState.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/State/SchemaState.cs @@ -15,11 +15,12 @@ using Squidex.Infrastructure; using Squidex.Infrastructure.Dispatching; using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.Reflection; +using Squidex.Infrastructure.States; namespace Squidex.Domain.Apps.Entities.Schemas.State { - public class SchemaState : DomainObjectState, - ISchemaEntity + [CollectionName("Schemas")] + public class SchemaState : DomainObjectState, ISchemaEntity { [JsonProperty] public NamedId AppId { get; set; } @@ -28,11 +29,17 @@ namespace Squidex.Domain.Apps.Entities.Schemas.State public string Name { get; set; } [JsonProperty] - public int TotalFields { get; set; } = 0; + public string Category { get; set; } + + [JsonProperty] + public int TotalFields { get; set; } [JsonProperty] public bool IsDeleted { get; set; } + [JsonProperty] + public bool IsSingleton { get; set; } + [JsonProperty] public string ScriptQuery { get; set; } @@ -61,6 +68,8 @@ namespace Squidex.Domain.Apps.Entities.Schemas.State { Name = @event.Name; + IsSingleton = @event.Singleton; + var schema = new Schema(@event.Name); if (@event.Properties != null) @@ -79,12 +88,33 @@ namespace Squidex.Domain.Apps.Entities.Schemas.State { TotalFields++; - var partitioning = - string.Equals(eventField.Partitioning, Partitioning.Language.Key, StringComparison.OrdinalIgnoreCase) ? - Partitioning.Language : - Partitioning.Invariant; + var partitioning = Partitioning.FromString(eventField.Partitioning); + + var field = registry.CreateRootField(TotalFields, eventField.Name, partitioning, eventField.Properties); + + if (field is ArrayField arrayField && eventField.Nested?.Count > 0) + { + foreach (var nestedEventField in eventField.Nested) + { + TotalFields++; + + var nestedField = registry.CreateNestedField(TotalFields, nestedEventField.Name, nestedEventField.Properties); + + if (nestedEventField.IsHidden) + { + nestedField = nestedField.Hide(); + } + + if (nestedEventField.IsDisabled) + { + nestedField = nestedField.Disable(); + } - var field = registry.CreateField(TotalFields, eventField.Name, partitioning, eventField.Properties); + arrayField = arrayField.AddField(nestedField); + } + + field = arrayField; + } if (eventField.IsHidden) { @@ -112,19 +142,30 @@ namespace Squidex.Domain.Apps.Entities.Schemas.State protected void On(FieldAdded @event, FieldRegistry registry) { - var partitioning = - string.Equals(@event.Partitioning, Partitioning.Language.Key, StringComparison.OrdinalIgnoreCase) ? - Partitioning.Language : - Partitioning.Invariant; + if (@event.ParentFieldId != null) + { + var field = registry.CreateNestedField(@event.FieldId.Id, @event.Name, @event.Properties); + + SchemaDef = SchemaDef.UpdateField(@event.ParentFieldId.Id, x => ((ArrayField)x).AddField(field)); + } + else + { + var partitioning = Partitioning.FromString(@event.Partitioning); - var field = registry.CreateField(@event.FieldId.Id, @event.Name, partitioning, @event.Properties); + var field = registry.CreateRootField(@event.FieldId.Id, @event.Name, partitioning, @event.Properties); - SchemaDef = SchemaDef.DeleteField(@event.FieldId.Id); - SchemaDef = SchemaDef.AddField(field); + SchemaDef = SchemaDef.DeleteField(@event.FieldId.Id); + SchemaDef = SchemaDef.AddField(field); + } TotalFields++; } + protected void On(SchemaCategoryChanged @event, FieldRegistry registry) + { + Category = @event.Name; + } + protected void On(SchemaPublished @event, FieldRegistry registry) { SchemaDef = SchemaDef.Publish(); @@ -142,42 +183,42 @@ namespace Squidex.Domain.Apps.Entities.Schemas.State protected void On(SchemaFieldsReordered @event, FieldRegistry registry) { - SchemaDef = SchemaDef.ReorderFields(@event.FieldIds); + SchemaDef = SchemaDef.ReorderFields(@event.FieldIds, @event.ParentFieldId?.Id); } protected void On(FieldUpdated @event, FieldRegistry registry) { - SchemaDef = SchemaDef.UpdateField(@event.FieldId.Id, @event.Properties); + SchemaDef = SchemaDef.UpdateField(@event.FieldId.Id, @event.Properties, @event.ParentFieldId?.Id); } protected void On(FieldLocked @event, FieldRegistry registry) { - SchemaDef = SchemaDef.LockField(@event.FieldId.Id); + SchemaDef = SchemaDef.LockField(@event.FieldId.Id, @event.ParentFieldId?.Id); } protected void On(FieldDisabled @event, FieldRegistry registry) { - SchemaDef = SchemaDef.DisableField(@event.FieldId.Id); + SchemaDef = SchemaDef.DisableField(@event.FieldId.Id, @event.ParentFieldId?.Id); } protected void On(FieldEnabled @event, FieldRegistry registry) { - SchemaDef = SchemaDef.EnableField(@event.FieldId.Id); + SchemaDef = SchemaDef.EnableField(@event.FieldId.Id, @event.ParentFieldId?.Id); } protected void On(FieldHidden @event, FieldRegistry registry) { - SchemaDef = SchemaDef.HideField(@event.FieldId.Id); + SchemaDef = SchemaDef.HideField(@event.FieldId.Id, @event.ParentFieldId?.Id); } protected void On(FieldShown @event, FieldRegistry registry) { - SchemaDef = SchemaDef.ShowField(@event.FieldId.Id); + SchemaDef = SchemaDef.ShowField(@event.FieldId.Id, @event.ParentFieldId?.Id); } protected void On(FieldDeleted @event, FieldRegistry registry) { - SchemaDef = SchemaDef.DeleteField(@event.FieldId.Id); + SchemaDef = SchemaDef.DeleteField(@event.FieldId.Id, @event.ParentFieldId?.Id); } protected void On(SchemaDeleted @event, FieldRegistry registry) diff --git a/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj b/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj index 431c71718..a3d6da666 100644 --- a/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj +++ b/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj @@ -1,4 +1,4 @@ - + netstandard2.0 @@ -14,10 +14,17 @@ - - - - + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + ..\..\Squidex.ruleset diff --git a/src/Squidex.Domain.Apps.Entities/SquidexCommand.cs b/src/Squidex.Domain.Apps.Entities/SquidexCommand.cs index a2c642e16..b9e15f41e 100644 --- a/src/Squidex.Domain.Apps.Entities/SquidexCommand.cs +++ b/src/Squidex.Domain.Apps.Entities/SquidexCommand.cs @@ -17,6 +17,6 @@ namespace Squidex.Domain.Apps.Entities public ClaimsPrincipal User { get; set; } - public long ExpectedVersion { get; set; } + public long ExpectedVersion { get; set; } = EtagVersion.Any; } } diff --git a/src/Squidex.Domain.Apps.Entities/SquidexDomainObjectGrain.cs b/src/Squidex.Domain.Apps.Entities/SquidexDomainObjectGrain.cs new file mode 100644 index 000000000..bf9c58327 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/SquidexDomainObjectGrain.cs @@ -0,0 +1,34 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Domain.Apps.Events; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.States; + +namespace Squidex.Domain.Apps.Entities +{ + public abstract class SquidexDomainObjectGrain : DomainObjectGrain where T : IDomainState, new() + { + protected SquidexDomainObjectGrain(IStore store, ISemanticLog log) + : base(store, log) + { + } + + public override void RaiseEvent(Envelope @event) + { + if (@event.Payload is AppEvent appEvent) + { + @event.SetAppId(appEvent.AppId.Id); + } + + base.RaiseEvent(@event); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/SquidexDomainObjectGrainLogSnapshots.cs b/src/Squidex.Domain.Apps.Entities/SquidexDomainObjectGrainLogSnapshots.cs new file mode 100644 index 000000000..425bdc4d6 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/SquidexDomainObjectGrainLogSnapshots.cs @@ -0,0 +1,34 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Domain.Apps.Events; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.States; + +namespace Squidex.Domain.Apps.Entities +{ + public abstract class SquidexDomainObjectGrainLogSnapshots : LogSnapshotDomainObjectGrain where T : IDomainState, new() + { + protected SquidexDomainObjectGrainLogSnapshots(IStore store, ISemanticLog log) + : base(store, log) + { + } + + public override void RaiseEvent(Envelope @event) + { + if (@event.Payload is AppEvent appEvent) + { + @event.SetAppId(appEvent.AppId.Id); + } + + base.RaiseEvent(@event); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/SquidexEntities.cs b/src/Squidex.Domain.Apps.Entities/SquidexEntities.cs new file mode 100644 index 000000000..8d82fce42 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/SquidexEntities.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Reflection; + +namespace Squidex.Domain.Apps.Entities +{ + public static class SquidexEntities + { + public static readonly Assembly Assembly = typeof(SquidexEntities).Assembly; + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Tags/GrainTagService.cs b/src/Squidex.Domain.Apps.Entities/Tags/GrainTagService.cs new file mode 100644 index 000000000..ad8c37457 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Tags/GrainTagService.cs @@ -0,0 +1,75 @@ +// ========================================================================== +// 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 Orleans; +using Squidex.Domain.Apps.Core.Tags; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Tags +{ + public sealed class GrainTagService : ITagService + { + private readonly IGrainFactory grainFactory; + + public string Name + { + get { return "Tags"; } + } + + public GrainTagService(IGrainFactory grainFactory) + { + Guard.NotNull(grainFactory, nameof(grainFactory)); + + this.grainFactory = grainFactory; + } + + public Task> NormalizeTagsAsync(Guid appId, string group, HashSet names, HashSet ids) + { + return GetGrain(appId, group).NormalizeTagsAsync(names, ids); + } + + public Task> GetTagIdsAsync(Guid appId, string group, HashSet names) + { + return GetGrain(appId, group).GetTagIdsAsync(names); + } + + public Task> DenormalizeTagsAsync(Guid appId, string group, HashSet ids) + { + return GetGrain(appId, group).DenormalizeTagsAsync(ids); + } + + public Task> GetTagsAsync(Guid appId, string group) + { + return GetGrain(appId, group).GetTagsAsync(); + } + + public Task GetExportableTagsAsync(Guid appId, string group) + { + return GetGrain(appId, group).GetExportableTagsAsync(); + } + + public Task RebuildTagsAsync(Guid appId, string group, TagSet tags) + { + return GetGrain(appId, group).RebuildAsync(tags); + } + + public Task ClearAsync(Guid appId, string group) + { + return GetGrain(appId, group).ClearAsync(); + } + + private ITagGrain GetGrain(Guid appId, string group) + { + Guard.NotNullOrEmpty(group, nameof(group)); + + return grainFactory.GetGrain($"{appId}_{group}"); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Tags/ITagGenerator.cs b/src/Squidex.Domain.Apps.Entities/Tags/ITagGenerator.cs new file mode 100644 index 000000000..504c85d53 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Tags/ITagGenerator.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; + +namespace Squidex.Domain.Apps.Entities.Tags +{ + public interface ITagGenerator + { + void GenerateTags(T source, HashSet tags); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Tags/ITagGrain.cs b/src/Squidex.Domain.Apps.Entities/Tags/ITagGrain.cs new file mode 100644 index 000000000..d43b6f022 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Tags/ITagGrain.cs @@ -0,0 +1,31 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Threading.Tasks; +using Orleans; +using Squidex.Domain.Apps.Core.Tags; + +namespace Squidex.Domain.Apps.Entities.Tags +{ + public interface ITagGrain : IGrainWithStringKey + { + Task> NormalizeTagsAsync(HashSet names, HashSet ids); + + Task> GetTagIdsAsync(HashSet names); + + Task> DenormalizeTagsAsync(HashSet ids); + + Task> GetTagsAsync(); + + Task GetExportableTagsAsync(); + + Task ClearAsync(); + + Task RebuildAsync(TagSet tags); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs b/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs new file mode 100644 index 000000000..d01b3c03c --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs @@ -0,0 +1,164 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.Tags; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Orleans; +using Squidex.Infrastructure.States; + +namespace Squidex.Domain.Apps.Entities.Tags +{ + public sealed class TagGrain : GrainOfString, ITagGrain + { + private readonly IStore store; + private IPersistence persistence; + private State state = new State(); + + [CollectionName("Index_Tags")] + public sealed class State + { + public TagSet Tags { get; set; } = new TagSet(); + } + + public TagGrain(IStore store) + { + Guard.NotNull(store, nameof(store)); + + this.store = store; + } + + public override Task OnActivateAsync(string key) + { + persistence = store.WithSnapshots(key, s => + { + state = s; + }); + + return persistence.ReadAsync(); + } + + public Task ClearAsync() + { + state = new State(); + + return persistence.DeleteAsync(); + } + + public Task RebuildAsync(TagSet tags) + { + state.Tags = tags; + + return persistence.WriteSnapshotAsync(state); + } + + public async Task> NormalizeTagsAsync(HashSet names, HashSet ids) + { + var result = new Dictionary(); + + if (names != null) + { + foreach (var tag in names) + { + if (!string.IsNullOrWhiteSpace(tag)) + { + var tagName = tag.ToLowerInvariant(); + var tagId = string.Empty; + + var found = state.Tags.FirstOrDefault(x => string.Equals(x.Value.Name, tagName, StringComparison.OrdinalIgnoreCase)); + + if (found.Value != null) + { + tagId = found.Key; + + if (ids == null || !ids.Contains(tagId)) + { + found.Value.Count++; + } + } + else + { + tagId = Guid.NewGuid().ToString(); + + state.Tags.Add(tagId, new Tag { Name = tagName }); + } + + result.Add(tagName, tagId); + } + } + } + + if (ids != null) + { + foreach (var id in ids) + { + if (!result.ContainsValue(id)) + { + if (state.Tags.TryGetValue(id, out var tagInfo)) + { + tagInfo.Count--; + + if (tagInfo.Count <= 0) + { + state.Tags.Remove(id); + } + } + } + } + } + + await persistence.WriteSnapshotAsync(state); + + return result; + } + + public Task> GetTagIdsAsync(HashSet names) + { + var result = new Dictionary(); + + foreach (var name in names) + { + var id = state.Tags.FirstOrDefault(x => string.Equals(x.Value.Name, name, StringComparison.OrdinalIgnoreCase)).Key; + + if (!string.IsNullOrWhiteSpace(id)) + { + result.Add(name, id); + } + } + + return Task.FromResult(result); + } + + public Task> DenormalizeTagsAsync(HashSet ids) + { + var result = new Dictionary(); + + foreach (var id in ids) + { + if (state.Tags.TryGetValue(id, out var tagInfo)) + { + result[id] = tagInfo.Name; + } + } + + return Task.FromResult(result); + } + + public Task> GetTagsAsync() + { + return Task.FromResult(state.Tags.Values.ToDictionary(x => x.Name, x => x.Count)); + } + + public Task GetExportableTagsAsync() + { + return Task.FromResult(state.Tags); + } + } +} diff --git a/src/Squidex.Domain.Apps.Events/Apps/AppArchived.cs b/src/Squidex.Domain.Apps.Events/Apps/AppArchived.cs new file mode 100644 index 000000000..02032eb9d --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/Apps/AppArchived.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Apps +{ + [EventType(nameof(AppArchived))] + public sealed class AppArchived : AppEvent + { + } +} diff --git a/src/Squidex.Domain.Apps.Events/Apps/AppPatternDeleted.cs b/src/Squidex.Domain.Apps.Events/Apps/AppPatternDeleted.cs index 0759e3fb3..3ae54ab42 100644 --- a/src/Squidex.Domain.Apps.Events/Apps/AppPatternDeleted.cs +++ b/src/Squidex.Domain.Apps.Events/Apps/AppPatternDeleted.cs @@ -14,7 +14,5 @@ namespace Squidex.Domain.Apps.Events.Apps public sealed class AppPatternDeleted : AppEvent { public Guid PatternId { get; set; } - - public string Name { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Events/Assets/AssetCreated.cs b/src/Squidex.Domain.Apps.Events/Assets/AssetCreated.cs index 53fea1234..2266f7f2e 100644 --- a/src/Squidex.Domain.Apps.Events/Assets/AssetCreated.cs +++ b/src/Squidex.Domain.Apps.Events/Assets/AssetCreated.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; using Squidex.Infrastructure.EventSourcing; namespace Squidex.Domain.Apps.Events.Assets @@ -25,5 +26,7 @@ namespace Squidex.Domain.Apps.Events.Assets public int? PixelWidth { get; set; } public int? PixelHeight { get; set; } + + public HashSet Tags { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Events/Assets/AssetTagged.cs b/src/Squidex.Domain.Apps.Events/Assets/AssetTagged.cs new file mode 100644 index 000000000..fb555b515 --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/Assets/AssetTagged.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Assets +{ + [EventType(nameof(AssetTagged))] + public sealed class AssetTagged : AssetEvent + { + public HashSet Tags { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Events/Contents/ContentChangesDiscarded.cs b/src/Squidex.Domain.Apps.Events/Contents/ContentChangesDiscarded.cs new file mode 100644 index 000000000..152c1f1a6 --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/Contents/ContentChangesDiscarded.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Contents +{ + [EventType(nameof(ContentChangesDiscarded))] + public sealed class ContentChangesDiscarded : ContentEvent + { + } +} diff --git a/src/Squidex.Domain.Apps.Events/Contents/ContentChangesPublished.cs b/src/Squidex.Domain.Apps.Events/Contents/ContentChangesPublished.cs new file mode 100644 index 000000000..2235161a4 --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/Contents/ContentChangesPublished.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Contents +{ + [EventType(nameof(ContentChangesPublished))] + public sealed class ContentChangesPublished : ContentEvent + { + } +} diff --git a/src/Squidex.Domain.Apps.Events/Contents/ContentSchedulingCancelled.cs b/src/Squidex.Domain.Apps.Events/Contents/ContentSchedulingCancelled.cs new file mode 100644 index 000000000..e585a64e1 --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/Contents/ContentSchedulingCancelled.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Contents +{ + [EventType(nameof(ContentSchedulingCancelled))] + public sealed class ContentSchedulingCancelled : ContentEvent + { + } +} diff --git a/src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs b/src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs index 2c97f5902..81b95e5fb 100644 --- a/src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs +++ b/src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs @@ -13,6 +13,8 @@ namespace Squidex.Domain.Apps.Events.Contents [EventType(nameof(ContentStatusChanged))] public sealed class ContentStatusChanged : ContentEvent { + public StatusChange? Change { get; set; } + public Status Status { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Events/Contents/ContentUpdateProposed.cs b/src/Squidex.Domain.Apps.Events/Contents/ContentUpdateProposed.cs new file mode 100644 index 000000000..7de7ccfcd --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/Contents/ContentUpdateProposed.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Contents +{ + [EventType(nameof(ContentUpdateProposed))] + public sealed class ContentUpdateProposed : ContentEvent + { + public NamedContentData Data { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Events/Schemas/FieldEvent.cs b/src/Squidex.Domain.Apps.Events/Schemas/FieldEvent.cs index 8887bf696..0c2a9690f 100644 --- a/src/Squidex.Domain.Apps.Events/Schemas/FieldEvent.cs +++ b/src/Squidex.Domain.Apps.Events/Schemas/FieldEvent.cs @@ -12,5 +12,7 @@ namespace Squidex.Domain.Apps.Events.Schemas public abstract class FieldEvent : SchemaEvent { public NamedId FieldId { get; set; } + + public NamedId ParentFieldId { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Events/Schemas/SchemaCategoryChanged.cs b/src/Squidex.Domain.Apps.Events/Schemas/SchemaCategoryChanged.cs new file mode 100644 index 000000000..412ef928c --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/Schemas/SchemaCategoryChanged.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Schemas +{ + [EventType(nameof(SchemaCategoryChanged))] + public sealed class SchemaCategoryChanged : SchemaEvent + { + public string Name { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Events/Schemas/SchemaCreated.cs b/src/Squidex.Domain.Apps.Events/Schemas/SchemaCreated.cs index 2e7c6ec2b..6f4f24295 100644 --- a/src/Squidex.Domain.Apps.Events/Schemas/SchemaCreated.cs +++ b/src/Squidex.Domain.Apps.Events/Schemas/SchemaCreated.cs @@ -20,6 +20,8 @@ namespace Squidex.Domain.Apps.Events.Schemas public SchemaProperties Properties { get; set; } + public bool Singleton { get; set; } + public bool Publish { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Events/Schemas/SchemaCreatedField.cs b/src/Squidex.Domain.Apps.Events/Schemas/SchemaCreatedField.cs index 3858eb897..1cf2fc2a8 100644 --- a/src/Squidex.Domain.Apps.Events/Schemas/SchemaCreatedField.cs +++ b/src/Squidex.Domain.Apps.Events/Schemas/SchemaCreatedField.cs @@ -5,22 +5,14 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Domain.Apps.Core.Schemas; +using System.Collections.Generic; namespace Squidex.Domain.Apps.Events.Schemas { - public sealed class SchemaCreatedField + public sealed class SchemaCreatedField : SchemaCreatedFieldBase { public string Partitioning { get; set; } - public string Name { get; set; } - - public bool IsHidden { get; set; } - - public bool IsLocked { get; set; } - - public bool IsDisabled { get; set; } - - public FieldProperties Properties { get; set; } + public List Nested { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Events/Schemas/SchemaCreatedFieldBase.cs b/src/Squidex.Domain.Apps.Events/Schemas/SchemaCreatedFieldBase.cs new file mode 100644 index 000000000..903c0c04b --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/Schemas/SchemaCreatedFieldBase.cs @@ -0,0 +1,24 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Schemas; + +namespace Squidex.Domain.Apps.Events.Schemas +{ + public abstract class SchemaCreatedFieldBase + { + public string Name { get; set; } + + public bool IsHidden { get; set; } + + public bool IsLocked { get; set; } + + public bool IsDisabled { get; set; } + + public FieldProperties Properties { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Events/Schemas/SchemaCreatedNestedField.cs b/src/Squidex.Domain.Apps.Events/Schemas/SchemaCreatedNestedField.cs new file mode 100644 index 000000000..bb2c22c50 --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/Schemas/SchemaCreatedNestedField.cs @@ -0,0 +1,13 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Events.Schemas +{ + public sealed class SchemaCreatedNestedField : SchemaCreatedFieldBase + { + } +} diff --git a/src/Squidex.Domain.Apps.Events/Schemas/SchemaFieldsReordered.cs b/src/Squidex.Domain.Apps.Events/Schemas/SchemaFieldsReordered.cs index 9a34fd94e..50ad79ed8 100644 --- a/src/Squidex.Domain.Apps.Events/Schemas/SchemaFieldsReordered.cs +++ b/src/Squidex.Domain.Apps.Events/Schemas/SchemaFieldsReordered.cs @@ -6,6 +6,7 @@ // ========================================================================== using System.Collections.Generic; +using Squidex.Infrastructure; using Squidex.Infrastructure.EventSourcing; namespace Squidex.Domain.Apps.Events.Schemas @@ -13,6 +14,8 @@ namespace Squidex.Domain.Apps.Events.Schemas [EventType(nameof(SchemaFieldsReordered))] public sealed class SchemaFieldsReordered : SchemaEvent { + public NamedId ParentFieldId { get; set; } + public List FieldIds { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj b/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj index 456d82e06..b6264eb9b 100644 --- a/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj +++ b/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj @@ -11,13 +11,12 @@ - - - - - - - + + + + + + ..\..\Squidex.ruleset diff --git a/src/Squidex.Domain.Users.MongoDb/Infrastructure/MongoPersistedGrantStore.cs b/src/Squidex.Domain.Users.MongoDb/Infrastructure/MongoPersistedGrantStore.cs index 0acdb0f01..0f839a848 100644 --- a/src/Squidex.Domain.Users.MongoDb/Infrastructure/MongoPersistedGrantStore.cs +++ b/src/Squidex.Domain.Users.MongoDb/Infrastructure/MongoPersistedGrantStore.cs @@ -39,8 +39,10 @@ namespace Squidex.Domain.Users.MongoDb.Infrastructure protected override Task SetupCollectionAsync(IMongoCollection collection) { return Task.WhenAll( - collection.Indexes.CreateOneAsync(Index.Ascending(x => x.ClientId)), - collection.Indexes.CreateOneAsync(Index.Ascending(x => x.SubjectId))); + collection.Indexes.CreateOneAsync( + new CreateIndexModel(Index.Ascending(x => x.ClientId))), + collection.Indexes.CreateOneAsync( + new CreateIndexModel(Index.Ascending(x => x.SubjectId)))); } public Task StoreAsync(PersistedGrant grant) diff --git a/src/Squidex.Domain.Users.MongoDb/MongoRoleStore.cs b/src/Squidex.Domain.Users.MongoDb/MongoRoleStore.cs index 899483807..745414c35 100644 --- a/src/Squidex.Domain.Users.MongoDb/MongoRoleStore.cs +++ b/src/Squidex.Domain.Users.MongoDb/MongoRoleStore.cs @@ -28,7 +28,8 @@ namespace Squidex.Domain.Users.MongoDb protected override Task SetupCollectionAsync(IMongoCollection collection) { - return collection.Indexes.CreateOneAsync(Index.Ascending(x => x.NormalizedName), new CreateIndexOptions { Unique = true }); + return collection.Indexes.CreateOneAsync( + new CreateIndexModel(Index.Ascending(x => x.NormalizedName), new CreateIndexOptions { Unique = true })); } protected override MongoCollectionSettings CollectionSettings() diff --git a/src/Squidex.Domain.Users.MongoDb/MongoUserStore.cs b/src/Squidex.Domain.Users.MongoDb/MongoUserStore.cs index 44cd87f61..686acd84d 100644 --- a/src/Squidex.Domain.Users.MongoDb/MongoUserStore.cs +++ b/src/Squidex.Domain.Users.MongoDb/MongoUserStore.cs @@ -12,6 +12,7 @@ using System.Security.Claims; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; +using MongoDB.Bson; using MongoDB.Driver; using Squidex.Infrastructure.MongoDb; using Squidex.Infrastructure.Tasks; @@ -48,9 +49,12 @@ namespace Squidex.Domain.Users.MongoDb protected override Task SetupCollectionAsync(IMongoCollection collection) { return Task.WhenAll( - collection.Indexes.CreateOneAsync(Index.Ascending("Logins.LoginProvider").Ascending("Logins.ProviderKey")), - collection.Indexes.CreateOneAsync(Index.Ascending(x => x.NormalizedUserName), new CreateIndexOptions { Unique = true }), - collection.Indexes.CreateOneAsync(Index.Ascending(x => x.NormalizedEmail), new CreateIndexOptions { Unique = true })); + collection.Indexes.CreateOneAsync( + new CreateIndexModel(Index.Ascending("Logins.LoginProvider").Ascending("Logins.ProviderKey"))), + collection.Indexes.CreateOneAsync( + new CreateIndexModel(Index.Ascending(x => x.NormalizedUserName), new CreateIndexOptions { Unique = true })), + collection.Indexes.CreateOneAsync( + new CreateIndexModel(Index.Ascending(x => x.NormalizedEmail), new CreateIndexOptions { Unique = true }))); } protected override MongoCollectionSettings CollectionSettings() @@ -72,11 +76,6 @@ namespace Squidex.Domain.Users.MongoDb return new MongoUser { Email = email, UserName = email }; } - public async Task FindByIdAsync(string id) - { - return await Collection.Find(x => x.Id == id).FirstOrDefaultAsync(); - } - public async Task FindByIdAsync(string userId, CancellationToken cancellationToken) { return await Collection.Find(x => x.Id == userId).FirstOrDefaultAsync(cancellationToken); @@ -388,5 +387,31 @@ namespace Squidex.Domain.Users.MongoDb return TaskHelper.Done; } + + public async Task FindByIdOrEmailAsync(string id) + { + if (ObjectId.TryParse(id, out _)) + { + return await Collection.Find(x => x.Id == id).FirstOrDefaultAsync(); + } + else + { + return await Collection.Find(x => x.NormalizedEmail == id.ToUpperInvariant()).FirstOrDefaultAsync(); + } + } + + public Task> QueryByEmailAsync(string email) + { + var result = Users; + + if (!string.IsNullOrWhiteSpace(email)) + { + var normalizedEmail = email.ToUpperInvariant(); + + result = result.Where(x => x.NormalizedEmail.Contains(normalizedEmail)); + } + + return Task.FromResult(result.Select(x => x).ToList()); + } } } diff --git a/src/Squidex.Domain.Users.MongoDb/MongoXmlRepository.cs b/src/Squidex.Domain.Users.MongoDb/MongoXmlRepository.cs index b57c58d53..b1d264b58 100644 --- a/src/Squidex.Domain.Users.MongoDb/MongoXmlRepository.cs +++ b/src/Squidex.Domain.Users.MongoDb/MongoXmlRepository.cs @@ -36,7 +36,7 @@ namespace Squidex.Domain.Users.MongoDb public void StoreElement(XElement element, string friendlyName) { - Collection.UpdateOne(Filter.Eq(x => x.Id, friendlyName), + Collection.UpdateOne(x => x.Id == friendlyName, Update.Set(x => x.Xml, element.ToString()), Upsert); } diff --git a/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj b/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj index 50c66cb45..8753ca0ef 100644 --- a/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj +++ b/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj @@ -13,13 +13,13 @@ - - - - - - - + + + + + + + ..\..\Squidex.ruleset diff --git a/src/Squidex.Domain.Users/IUserEvents.cs b/src/Squidex.Domain.Users/IUserEvents.cs index fc1611710..e92dc1a6a 100644 --- a/src/Squidex.Domain.Users/IUserEvents.cs +++ b/src/Squidex.Domain.Users/IUserEvents.cs @@ -12,5 +12,7 @@ namespace Squidex.Domain.Users public interface IUserEvents { void OnUserRegistered(IUser user); + + void OnConsentGiven(IUser user); } } diff --git a/src/Squidex.Domain.Users/NoopUserEvents.cs b/src/Squidex.Domain.Users/NoopUserEvents.cs index 4890f15f5..9fb142938 100644 --- a/src/Squidex.Domain.Users/NoopUserEvents.cs +++ b/src/Squidex.Domain.Users/NoopUserEvents.cs @@ -11,6 +11,10 @@ namespace Squidex.Domain.Users { public sealed class NoopUserEvents : IUserEvents { + public void OnConsentGiven(IUser user) + { + } + public void OnUserRegistered(IUser user) { } diff --git a/src/Squidex.Domain.Users/PwnedPasswordValidator.cs b/src/Squidex.Domain.Users/PwnedPasswordValidator.cs new file mode 100644 index 000000000..44087694c --- /dev/null +++ b/src/Squidex.Domain.Users/PwnedPasswordValidator.cs @@ -0,0 +1,55 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using SharpPwned.NET; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Log; +using Squidex.Shared.Users; + +namespace Squidex.Domain.Users +{ + public sealed class PwnedPasswordValidator : IPasswordValidator + { + private const string ErrorCode = "PwnedError"; + private const string ErrorText = "This password has previously appeared in a data breach and should never be used. If you've ever used it anywhere before, change it!"; + private static readonly IdentityResult Error = IdentityResult.Failed(new IdentityError { Code = ErrorCode, Description = ErrorText }); + + private readonly HaveIBeenPwnedRestClient client = new HaveIBeenPwnedRestClient(); + private readonly ISemanticLog log; + + public PwnedPasswordValidator(ISemanticLog log) + { + Guard.NotNull(log, nameof(log)); + + this.log = log; + } + + public async Task ValidateAsync(UserManager manager, IUser user, string password) + { + try + { + var isBreached = await client.IsPasswordPwned(password); + + if (isBreached) + { + return Error; + } + } + catch (Exception ex) + { + log.LogError(ex, w => w + .WriteProperty("operation", "CheckPasswordPwned") + .WriteProperty("status", "Failed")); + } + + return IdentityResult.Success; + } + } +} diff --git a/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj b/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj index 10b6e916a..8a4d33405 100644 --- a/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj +++ b/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj @@ -11,12 +11,13 @@ - - - - + + + + + - + ..\..\Squidex.ruleset diff --git a/src/Squidex.Domain.Users/UserExtensions.cs b/src/Squidex.Domain.Users/UserExtensions.cs deleted file mode 100644 index cc87e88c7..000000000 --- a/src/Squidex.Domain.Users/UserExtensions.cs +++ /dev/null @@ -1,117 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Linq; -using Squidex.Infrastructure; -using Squidex.Shared.Identity; -using Squidex.Shared.Users; - -namespace Squidex.Domain.Users -{ - public static class UserExtensions - { - public static void SetDisplayName(this IUser user, string displayName) - { - user.SetClaim(SquidexClaimTypes.SquidexDisplayName, displayName); - } - - public static void SetPictureUrl(this IUser user, string pictureUrl) - { - user.SetClaim(SquidexClaimTypes.SquidexPictureUrl, pictureUrl); - } - - public static void SetPictureUrlToStore(this IUser user) - { - user.SetClaim(SquidexClaimTypes.SquidexPictureUrl, "store"); - } - - public static void SetPictureUrlFromGravatar(this IUser user, string email) - { - user.SetClaim(SquidexClaimTypes.SquidexPictureUrl, GravatarHelper.CreatePictureUrl(email)); - } - - public static void SetConsent(this IUser user) - { - user.SetClaim(SquidexClaimTypes.SquidexConsent, "true"); - } - - public static void SetConsentForEmails(this IUser user, bool value) - { - user.SetClaim(SquidexClaimTypes.SquidexConsentForEmails, value.ToString()); - } - - public static bool HasConsent(this IUser user) - { - return user.HasClaimValue(SquidexClaimTypes.SquidexConsent, "true"); - } - - public static bool HasConsentForEmails(this IUser user) - { - return user.HasClaimValue(SquidexClaimTypes.SquidexConsentForEmails, "true"); - } - - public static bool HasDisplayName(this IUser user) - { - return user.HasClaim(SquidexClaimTypes.SquidexDisplayName); - } - - public static bool HasPictureUrl(this IUser user) - { - return user.HasClaim(SquidexClaimTypes.SquidexPictureUrl); - } - - public static bool IsPictureUrlStored(this IUser user) - { - return user.HasClaimValue(SquidexClaimTypes.SquidexPictureUrl, "store"); - } - - public static string PictureUrl(this IUser user) - { - return user.GetClaimValue(SquidexClaimTypes.SquidexPictureUrl); - } - - public static string DisplayName(this IUser user) - { - return user.GetClaimValue(SquidexClaimTypes.SquidexDisplayName); - } - - public static string GetClaimValue(this IUser user, string claim) - { - return user.Claims.FirstOrDefault(x => string.Equals(x.Type, claim, StringComparison.OrdinalIgnoreCase))?.Value; - } - - public static bool HasClaim(this IUser user, string claim) - { - return user.Claims.Any(x => string.Equals(x.Type, claim, StringComparison.OrdinalIgnoreCase)); - } - - public static bool HasClaimValue(this IUser user, string claim, string value) - { - return user.Claims.Any(x => string.Equals(x.Type, claim, StringComparison.OrdinalIgnoreCase) && string.Equals(x.Value, value, StringComparison.OrdinalIgnoreCase)); - } - - public static string PictureNormalizedUrl(this IUser user) - { - var url = user.Claims.FirstOrDefault(x => x.Type == SquidexClaimTypes.SquidexPictureUrl)?.Value; - - if (!string.IsNullOrWhiteSpace(url) && Uri.IsWellFormedUriString(url, UriKind.Absolute) && url.Contains("gravatar")) - { - if (url.Contains("?")) - { - url += "&d=404"; - } - else - { - url += "?d=404"; - } - } - - return url; - } - } -} diff --git a/src/Squidex.Domain.Users/UserManagerExtensions.cs b/src/Squidex.Domain.Users/UserManagerExtensions.cs index 302b025d1..c84c61422 100644 --- a/src/Squidex.Domain.Users/UserManagerExtensions.cs +++ b/src/Squidex.Domain.Users/UserManagerExtensions.cs @@ -71,8 +71,9 @@ namespace Squidex.Domain.Users return user; } - public static Task UpdateAsync(this UserManager userManager, IUser user, string email, string displayName) + public static Task UpdateAsync(this UserManager userManager, IUser user, string email, string displayName, bool hidden) { + user.SetHidden(hidden); user.SetEmail(email); user.SetDisplayName(displayName); diff --git a/src/Squidex.Infrastructure.Azure/Assets/AzureBlobAssetStore.cs b/src/Squidex.Infrastructure.Azure/Assets/AzureBlobAssetStore.cs index 258a37ae2..4239d2c4b 100644 --- a/src/Squidex.Infrastructure.Azure/Assets/AzureBlobAssetStore.cs +++ b/src/Squidex.Infrastructure.Azure/Assets/AzureBlobAssetStore.cs @@ -7,6 +7,7 @@ using System; using System.IO; +using System.Threading; using System.Threading.Tasks; using Microsoft.WindowsAzure.Storage; using Microsoft.WindowsAzure.Storage.Blob; @@ -15,8 +16,6 @@ namespace Squidex.Infrastructure.Assets { public class AzureBlobAssetStore : IAssetStore, IInitializable { - private const string AssetVersion = "AssetVersion"; - private const string AssetId = "AssetId"; private readonly string containerName; private readonly string connectionString; private CloudBlobContainer blobContainer; @@ -56,73 +55,93 @@ namespace Squidex.Infrastructure.Assets return new Uri(blobContainer.StorageUri.PrimaryUri, $"/{containerName}/{blobName}").ToString(); } - public async Task CopyTemporaryAsync(string name, string id, long version, string suffix) + public async Task CopyAsync(string sourceFileName, string id, long version, string suffix, CancellationToken ct = default(CancellationToken)) { - var blobName = GetObjectName(id, version, suffix); - var blobRef = blobContainer.GetBlobReference(blobName); + var targetName = GetObjectName(id, version, suffix); + var targetBlob = blobContainer.GetBlobReference(targetName); - var tempBlob = blobContainer.GetBlockBlobReference(name); + var sourceBlob = blobContainer.GetBlockBlobReference(sourceFileName); try { - await blobRef.StartCopyAsync(tempBlob.Uri); + await targetBlob.StartCopyAsync(sourceBlob.Uri, null, AccessCondition.GenerateIfNotExistsCondition(), null, null, ct); - while (blobRef.CopyState.Status == CopyStatus.Pending) + while (targetBlob.CopyState.Status == CopyStatus.Pending) { - await Task.Delay(50); - await blobRef.FetchAttributesAsync(); + ct.ThrowIfCancellationRequested(); + + await Task.Delay(50, ct); + await targetBlob.FetchAttributesAsync(null, null, null, ct); } - if (blobRef.CopyState.Status != CopyStatus.Success) + if (targetBlob.CopyState.Status != CopyStatus.Success) { - throw new StorageException($"Copy of temporary file failed: {blobRef.CopyState.Status}"); + throw new StorageException($"Copy of temporary file failed: {targetBlob.CopyState.Status}"); } } + catch (StorageException ex) when (ex.RequestInformation.HttpStatusCode == 409) + { + throw new AssetAlreadyExistsException(targetName); + } catch (StorageException ex) when (ex.RequestInformation.HttpStatusCode == 404) { - throw new AssetNotFoundException($"Asset {name} not found.", ex); + throw new AssetNotFoundException(sourceFileName, ex); } } - public async Task DownloadAsync(string id, long version, string suffix, Stream stream) + public async Task DownloadAsync(string id, long version, string suffix, Stream stream, CancellationToken ct = default(CancellationToken)) { - var blobName = GetObjectName(id, version, suffix); - var blobRef = blobContainer.GetBlockBlobReference(blobName); + var blob = blobContainer.GetBlockBlobReference(GetObjectName(id, version, suffix)); try { - await blobRef.DownloadToStreamAsync(stream); + await blob.DownloadToStreamAsync(stream, null, null, null, ct); } catch (StorageException ex) when (ex.RequestInformation.HttpStatusCode == 404) { - throw new AssetNotFoundException($"Asset {id}, {version} not found.", ex); + throw new AssetNotFoundException($"Id={id}, Version={version}", ex); } } - public async Task UploadAsync(string id, long version, string suffix, Stream stream) + public Task UploadAsync(string id, long version, string suffix, Stream stream, CancellationToken ct = default(CancellationToken)) { - var blobName = GetObjectName(id, version, suffix); - var blobRef = blobContainer.GetBlockBlobReference(blobName); + return UploadCoreAsync(GetObjectName(id, version, suffix), stream, ct); + } + + public Task UploadAsync(string fileName, Stream stream, CancellationToken ct = default(CancellationToken)) + { + return UploadCoreAsync(fileName, stream, ct); + } - blobRef.Metadata[AssetVersion] = version.ToString(); - blobRef.Metadata[AssetId] = id; + public Task DeleteAsync(string id, long version, string suffix) + { + return DeleteCoreAsync(GetObjectName(id, version, suffix)); + } - await blobRef.UploadFromStreamAsync(stream); - await blobRef.SetMetadataAsync(); + public Task DeleteAsync(string fileName) + { + return DeleteCoreAsync(fileName); } - public async Task UploadTemporaryAsync(string name, Stream stream) + private Task DeleteCoreAsync(string blobName) { - var tempBlob = blobContainer.GetBlockBlobReference(name); + var blob = blobContainer.GetBlockBlobReference(blobName); - await tempBlob.UploadFromStreamAsync(stream); + return blob.DeleteIfExistsAsync(); } - public async Task DeleteTemporaryAsync(string name) + private async Task UploadCoreAsync(string blobName, Stream stream, CancellationToken ct) { - var tempBlob = blobContainer.GetBlockBlobReference(name); + try + { + var tempBlob = blobContainer.GetBlockBlobReference(blobName); - await tempBlob.DeleteIfExistsAsync(); + await tempBlob.UploadFromStreamAsync(stream, AccessCondition.GenerateIfNotExistsCondition(), null, null, ct); + } + catch (StorageException ex) when (ex.RequestInformation.HttpStatusCode == 409) + { + throw new AssetAlreadyExistsException(blobName); + } } private string GetObjectName(string id, long version, string suffix) diff --git a/src/Squidex.Infrastructure.Azure/Squidex.Infrastructure.Azure.csproj b/src/Squidex.Infrastructure.Azure/Squidex.Infrastructure.Azure.csproj index 7f009bb3d..047715eb6 100644 --- a/src/Squidex.Infrastructure.Azure/Squidex.Infrastructure.Azure.csproj +++ b/src/Squidex.Infrastructure.Azure/Squidex.Infrastructure.Azure.csproj @@ -4,9 +4,9 @@ Squidex.Infrastructure - - - + + + diff --git a/src/Squidex.Infrastructure.GetEventStore/EventSourcing/Formatter.cs b/src/Squidex.Infrastructure.GetEventStore/EventSourcing/Formatter.cs index aefd883b2..c28abf358 100644 --- a/src/Squidex.Infrastructure.GetEventStore/EventSourcing/Formatter.cs +++ b/src/Squidex.Infrastructure.GetEventStore/EventSourcing/Formatter.cs @@ -24,6 +24,7 @@ namespace Squidex.Infrastructure.EventSourcing var eventData = new EventData { Type = @event.EventType, Payload = body, Metadata = meta }; return new StoredEvent( + @event.EventStreamId, resolvedEvent.OriginalEventNumber.ToString(), resolvedEvent.Event.EventNumber, eventData); diff --git a/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs b/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs index 9e4d1a4d8..d948039e2 100644 --- a/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs +++ b/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs @@ -11,6 +11,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using EventStore.ClientAPI; +using Squidex.Infrastructure.Log; namespace Squidex.Infrastructure.EventSourcing { @@ -20,7 +21,7 @@ namespace Squidex.Infrastructure.EventSourcing private const int ReadPageSize = 500; private readonly IEventStoreConnection connection; private readonly string prefix; - private ProjectionClient projectionClient; + private readonly ProjectionClient projectionClient; public GetEventStore(IEventStoreConnection connection, string prefix, string projectionHost) { @@ -49,7 +50,7 @@ namespace Squidex.Infrastructure.EventSourcing public IEventSubscription CreateSubscription(IEventSubscriber subscriber, string streamFilter, string position = null) { - return new GetEventStoreSubscription(connection, subscriber, projectionClient, prefix, position, streamFilter); + return new GetEventStoreSubscription(connection, subscriber, projectionClient, position, streamFilter); } public Task CreateIndexAsync(string property) @@ -59,33 +60,30 @@ namespace Squidex.Infrastructure.EventSourcing public async Task QueryAsync(Func callback, string property, object value, string position = null, CancellationToken ct = default(CancellationToken)) { - var streamName = await projectionClient.CreateProjectionAsync(property, value); + using (Profiler.TraceMethod()) + { + var streamName = await projectionClient.CreateProjectionAsync(property, value); - var sliceStart = projectionClient.ParsePosition(position); + var sliceStart = projectionClient.ParsePosition(position); - await QueryAsync(callback, streamName, sliceStart, ct); + await QueryAsync(callback, streamName, sliceStart, ct); + } } public async Task QueryAsync(Func callback, string streamFilter = null, string position = null, CancellationToken ct = default(CancellationToken)) { - var streamName = await projectionClient.CreateProjectionAsync(streamFilter); - - var sliceStart = projectionClient.ParsePosition(position); + using (Profiler.TraceMethod()) + { + var streamName = await projectionClient.CreateProjectionAsync(streamFilter); - await QueryAsync(callback, streamName, sliceStart, ct); - } + var sliceStart = projectionClient.ParsePosition(position); - private Task QueryAsync(Func callback, string streamName, long sliceStart, CancellationToken ct) - { - return QueryAsync(callback, GetStreamName(streamName), sliceStart, ct); + await QueryAsync(callback, streamName, sliceStart, ct); + } } - public async Task> QueryAsync(string streamName, long streamPosition = 0) + private async Task QueryAsync(Func callback, string streamName, long sliceStart, CancellationToken ct) { - var result = new List(); - - var sliceStart = streamPosition; - StreamEventsSlice currentSlice; do { @@ -99,13 +97,47 @@ namespace Squidex.Infrastructure.EventSourcing { var storedEvent = Formatter.Read(resolved); - result.Add(storedEvent); + await callback(storedEvent); } } } - while (!currentSlice.IsEndOfStream); + while (!currentSlice.IsEndOfStream && !ct.IsCancellationRequested); + } - return result; + public async Task> QueryAsync(string streamName, long streamPosition = 0) + { + using (Profiler.TraceMethod()) + { + var result = new List(); + + var sliceStart = streamPosition; + + StreamEventsSlice currentSlice; + do + { + currentSlice = await connection.ReadStreamEventsForwardAsync(streamName, sliceStart, ReadPageSize, false); + + if (currentSlice.Status == SliceReadStatus.Success) + { + sliceStart = currentSlice.NextEventNumber; + + foreach (var resolved in currentSlice.Events) + { + var storedEvent = Formatter.Read(resolved); + + result.Add(storedEvent); + } + } + } + while (!currentSlice.IsEndOfStream); + + return result; + } + } + + public Task DeleteStreamAsync(string streamName) + { + return connection.DeleteStreamAsync(streamName, ExpectedVersion.Any); } public Task AppendAsync(Guid commitId, string streamName, ICollection events) @@ -122,34 +154,42 @@ namespace Squidex.Infrastructure.EventSourcing private async Task AppendEventsInternalAsync(string streamName, long expectedVersion, ICollection events) { - Guard.NotNullOrEmpty(streamName, nameof(streamName)); - Guard.NotNull(events, nameof(events)); - - if (events.Count == 0) + using (Profiler.TraceMethod(nameof(AppendAsync))) { - return; - } + Guard.NotNullOrEmpty(streamName, nameof(streamName)); + Guard.NotNull(events, nameof(events)); - var eventsToSave = events.Select(Formatter.Write).ToList(); + if (events.Count == 0) + { + return; + } - if (eventsToSave.Count < WritePageSize) - { - await connection.AppendToStreamAsync(GetStreamName(streamName), expectedVersion, eventsToSave); - } - else - { - using (var transaction = await connection.StartTransactionAsync(GetStreamName(streamName), expectedVersion)) + var eventsToSave = events.Select(Formatter.Write).ToList(); + + if (eventsToSave.Count < WritePageSize) + { + await connection.AppendToStreamAsync(GetStreamName(streamName), expectedVersion, eventsToSave); + } + else { - for (var p = 0; p < eventsToSave.Count; p += WritePageSize) + using (var transaction = await connection.StartTransactionAsync(GetStreamName(streamName), expectedVersion)) { - await transaction.WriteAsync(eventsToSave.Skip(p).Take(WritePageSize)); - } + for (var p = 0; p < eventsToSave.Count; p += WritePageSize) + { + await transaction.WriteAsync(eventsToSave.Skip(p).Take(WritePageSize)); + } - await transaction.CommitAsync(); + await transaction.CommitAsync(); + } } } } + public Task DeleteManyAsync(string property, object value) + { + throw new NotSupportedException(); + } + private string GetStreamName(string streamName) { return $"{prefix}-{streamName}"; diff --git a/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStoreSubscription.cs b/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStoreSubscription.cs index cbf1559f5..e77d4a204 100644 --- a/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStoreSubscription.cs +++ b/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStoreSubscription.cs @@ -23,19 +23,19 @@ namespace Squidex.Infrastructure.EventSourcing IEventStoreConnection connection, IEventSubscriber subscriber, ProjectionClient projectionClient, - string prefix, string position, string streamFilter) { Guard.NotNull(subscriber, nameof(subscriber)); this.connection = connection; + this.position = projectionClient.ParsePositionOrNull(position); - this.subscriber = subscriber; var streamName = projectionClient.CreateProjectionAsync(streamFilter).Result; - subscription = SubscribeToStream(streamName); + this.subscriber = subscriber; + this.subscription = SubscribeToStream(streamName); } public Task StopAsync() @@ -45,6 +45,10 @@ namespace Squidex.Infrastructure.EventSourcing return TaskHelper.Done; } + public void WakeUp() + { + } + private EventStoreCatchUpSubscription SubscribeToStream(string streamName) { var settings = CatchUpSubscriptionSettings.Default; diff --git a/src/Squidex.Infrastructure.GetEventStore/Squidex.Infrastructure.GetEventStore.csproj b/src/Squidex.Infrastructure.GetEventStore/Squidex.Infrastructure.GetEventStore.csproj index 4ebcb4f28..d9e87eab5 100644 --- a/src/Squidex.Infrastructure.GetEventStore/Squidex.Infrastructure.GetEventStore.csproj +++ b/src/Squidex.Infrastructure.GetEventStore/Squidex.Infrastructure.GetEventStore.csproj @@ -8,9 +8,9 @@ True - - - + + + diff --git a/src/Squidex.Infrastructure.GoogleCloud/Assets/GoogleCloudAssetStore.cs b/src/Squidex.Infrastructure.GoogleCloud/Assets/GoogleCloudAssetStore.cs index ec1bad1c7..9a8a31e7c 100644 --- a/src/Squidex.Infrastructure.GoogleCloud/Assets/GoogleCloudAssetStore.cs +++ b/src/Squidex.Infrastructure.GoogleCloud/Assets/GoogleCloudAssetStore.cs @@ -9,6 +9,7 @@ using System; using System.IO; using System.Linq; using System.Net; +using System.Threading; using System.Threading.Tasks; using Google; using Google.Cloud.Storage.V1; @@ -17,6 +18,8 @@ namespace Squidex.Infrastructure.Assets { public sealed class GoogleCloudAssetStore : IAssetStore, IInitializable { + private static readonly UploadObjectOptions IfNotExists = new UploadObjectOptions { IfGenerationMatch = 0 }; + private static readonly CopyObjectOptions IfNotExistsCopy = new CopyObjectOptions { IfGenerationMatch = 0 }; private readonly string bucketName; private StorageClient storageClient; @@ -48,58 +51,79 @@ namespace Squidex.Infrastructure.Assets return $"https://storage.cloud.google.com/{bucketName}/{objectName}"; } - public Task UploadTemporaryAsync(string name, Stream stream) - { - return storageClient.UploadObjectAsync(bucketName, name, "application/octet-stream", stream); - } - - public async Task UploadAsync(string id, long version, string suffix, Stream stream) + public async Task CopyAsync(string sourceFileName, string id, long version, string suffix, CancellationToken ct = default(CancellationToken)) { var objectName = GetObjectName(id, version, suffix); - await storageClient.UploadObjectAsync(bucketName, objectName, "application/octet-stream", stream); + try + { + await storageClient.CopyObjectAsync(bucketName, sourceFileName, bucketName, objectName, IfNotExistsCopy, ct); + } + catch (GoogleApiException ex) when (ex.HttpStatusCode == HttpStatusCode.NotFound) + { + throw new AssetNotFoundException(sourceFileName, ex); + } + catch (GoogleApiException ex) when (ex.HttpStatusCode == HttpStatusCode.PreconditionFailed) + { + throw new AssetAlreadyExistsException(objectName); + } } - public async Task CopyTemporaryAsync(string name, string id, long version, string suffix) + public async Task DownloadAsync(string id, long version, string suffix, Stream stream, CancellationToken ct = default(CancellationToken)) { var objectName = GetObjectName(id, version, suffix); try { - await storageClient.CopyObjectAsync(bucketName, name, bucketName, objectName); + await storageClient.DownloadObjectAsync(bucketName, objectName, stream, cancellationToken: ct); } catch (GoogleApiException ex) when (ex.HttpStatusCode == HttpStatusCode.NotFound) { - throw new AssetNotFoundException($"Asset {name} not found.", ex); + throw new AssetNotFoundException($"Id={id}, Version={version}", ex); } } - public async Task DownloadAsync(string id, long version, string suffix, Stream stream) + public Task UploadAsync(string id, long version, string suffix, Stream stream, CancellationToken ct = default(CancellationToken)) { - var objectName = GetObjectName(id, version, suffix); + return UploadCoreAsync(GetObjectName(id, version, suffix), stream, ct); + } + public Task UploadAsync(string fileName, Stream stream, CancellationToken ct = default(CancellationToken)) + { + return UploadCoreAsync(fileName, stream, ct); + } + + public Task DeleteAsync(string id, long version, string suffix) + { + return DeleteCoreAsync(GetObjectName(id, version, suffix)); + } + + public Task DeleteAsync(string fileName) + { + return DeleteCoreAsync(fileName); + } + + private async Task UploadCoreAsync(string objectName, Stream stream, CancellationToken ct) + { try { - await storageClient.DownloadObjectAsync(bucketName, objectName, stream); + await storageClient.UploadObjectAsync(bucketName, objectName, "application/octet-stream", stream, IfNotExists, ct); } - catch (GoogleApiException ex) when (ex.HttpStatusCode == HttpStatusCode.NotFound) + catch (GoogleApiException ex) when (ex.HttpStatusCode == HttpStatusCode.PreconditionFailed) { - throw new AssetNotFoundException($"Asset {id}, {version} not found.", ex); + throw new AssetAlreadyExistsException(objectName); } } - public async Task DeleteTemporaryAsync(string name) + private async Task DeleteCoreAsync(string objectName) { try { - await storageClient.DeleteObjectAsync(bucketName, name); + await storageClient.DeleteObjectAsync(bucketName, objectName); } - catch (GoogleApiException ex) + catch (GoogleApiException ex) when (ex.HttpStatusCode == HttpStatusCode.NotFound) { - if (ex.HttpStatusCode != HttpStatusCode.NotFound) - { - throw; - } + return; } } diff --git a/src/Squidex.Infrastructure.GoogleCloud/Squidex.Infrastructure.GoogleCloud.csproj b/src/Squidex.Infrastructure.GoogleCloud/Squidex.Infrastructure.GoogleCloud.csproj index 0872cb47e..c12b77aa4 100644 --- a/src/Squidex.Infrastructure.GoogleCloud/Squidex.Infrastructure.GoogleCloud.csproj +++ b/src/Squidex.Infrastructure.GoogleCloud/Squidex.Infrastructure.GoogleCloud.csproj @@ -8,10 +8,10 @@ True - - - - + + + + diff --git a/src/Squidex.Infrastructure.MongoDb/Assets/MongoGridFsAssetStore.cs b/src/Squidex.Infrastructure.MongoDb/Assets/MongoGridFsAssetStore.cs new file mode 100644 index 000000000..15dc5619b --- /dev/null +++ b/src/Squidex.Infrastructure.MongoDb/Assets/MongoGridFsAssetStore.cs @@ -0,0 +1,134 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MongoDB.Bson; +using MongoDB.Driver; +using MongoDB.Driver.GridFS; + +namespace Squidex.Infrastructure.Assets +{ + public sealed class MongoGridFsAssetStore : IAssetStore, IInitializable + { + private const int BufferSize = 81920; + private readonly IGridFSBucket bucket; + + public MongoGridFsAssetStore(IGridFSBucket bucket) + { + Guard.NotNull(bucket, nameof(bucket)); + + this.bucket = bucket; + } + + public void Initialize() + { + try + { + bucket.Database.ListCollections(); + } + catch (MongoException ex) + { + throw new ConfigurationException($"Cannot connect to Mongo GridFS bucket '${bucket.Options.BucketName}'.", ex); + } + } + + public string GenerateSourceUrl(string id, long version, string suffix) + { + return "UNSUPPORTED"; + } + + public async Task CopyAsync(string sourceFileName, string id, long version, string suffix, CancellationToken ct = default(CancellationToken)) + { + try + { + var target = GetFileName(id, version, suffix); + + using (var readStream = await bucket.OpenDownloadStreamAsync(sourceFileName, cancellationToken: ct)) + { + await UploadFileCoreAsync(target, readStream, ct); + } + } + catch (GridFSFileNotFoundException ex) + { + throw new AssetNotFoundException(sourceFileName, ex); + } + } + + public async Task DownloadAsync(string id, long version, string suffix, Stream stream, CancellationToken ct = default(CancellationToken)) + { + try + { + var name = GetFileName(id, version, suffix); + + using (var readStream = await bucket.OpenDownloadStreamAsync(name, cancellationToken: ct)) + { + await readStream.CopyToAsync(stream, BufferSize, ct); + } + } + catch (GridFSFileNotFoundException ex) + { + throw new AssetNotFoundException($"Id={id}, Version={version}", ex); + } + } + + public Task UploadAsync(string id, long version, string suffix, Stream stream, CancellationToken ct = default(CancellationToken)) + { + return UploadFileCoreAsync(GetFileName(id, version, suffix), stream, ct); + } + + public Task UploadAsync(string fileName, Stream stream, CancellationToken ct = default(CancellationToken)) + { + return UploadFileCoreAsync(fileName, stream, ct); + } + + public Task DeleteAsync(string id, long version, string suffix) + { + return DeleteCoreAsync(GetFileName(id, version, suffix)); + } + + public Task DeleteAsync(string fileName) + { + return DeleteCoreAsync(fileName); + } + + private async Task DeleteCoreAsync(string id) + { + try + { + await bucket.DeleteAsync(id); + } + catch (GridFSFileNotFoundException) + { + return; + } + } + + private async Task UploadFileCoreAsync(string id, Stream stream, CancellationToken ct = default(CancellationToken)) + { + try + { + await bucket.UploadFromStreamAsync(id, id, stream, cancellationToken: ct); + } + catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) + { + throw new AssetAlreadyExistsException(id); + } + catch (MongoBulkWriteException ex) when (ex.WriteErrors.Any(x => x.Category == ServerErrorCategory.DuplicateKey)) + { + throw new AssetAlreadyExistsException(id); + } + } + + private static string GetFileName(string id, long version, string suffix) + { + return string.Join("_", new[] { id, version.ToString(), suffix }.Where(x => !string.IsNullOrWhiteSpace(x))); + } + } +} \ No newline at end of file diff --git a/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEvent.cs b/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEvent.cs index c33e7aee7..395fa594f 100644 --- a/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEvent.cs +++ b/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEvent.cs @@ -7,6 +7,7 @@ using MongoDB.Bson.Serialization.Attributes; using Newtonsoft.Json.Linq; +using Squidex.Infrastructure.MongoDb; namespace Squidex.Infrastructure.EventSourcing { @@ -16,7 +17,7 @@ namespace Squidex.Infrastructure.EventSourcing [BsonRequired] public string Type { get; set; } - [BsonElement] + [BsonJson] [BsonRequired] public string Payload { get; set; } diff --git a/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore.cs b/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore.cs index e5779e42e..9438d62d6 100644 --- a/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore.cs +++ b/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore.cs @@ -46,8 +46,10 @@ namespace Squidex.Infrastructure.EventSourcing protected override Task SetupCollectionAsync(IMongoCollection collection) { return Task.WhenAll( - collection.Indexes.CreateOneAsync(Index.Ascending(x => x.Timestamp).Ascending(x => x.EventStream)), - collection.Indexes.CreateOneAsync(Index.Ascending(x => x.EventStream).Descending(x => x.EventStreamOffset), new CreateIndexOptions { Unique = true })); + collection.Indexes.CreateOneAsync( + new CreateIndexModel(Index.Ascending(x => x.Timestamp).Ascending(x => x.EventStream))), + collection.Indexes.CreateOneAsync( + new CreateIndexModel(Index.Ascending(x => x.EventStream).Descending(x => x.EventStreamOffset), new CreateIndexOptions { Unique = true }))); } } } \ No newline at end of file diff --git a/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs b/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs index eed2d0bce..a9e31eb43 100644 --- a/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs +++ b/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs @@ -7,10 +7,10 @@ using System; using System.Collections.Generic; -using System.Reactive.Linq; using System.Threading; using System.Threading.Tasks; using MongoDB.Driver; +using Squidex.Infrastructure.Log; using Squidex.Infrastructure.MongoDb; namespace Squidex.Infrastructure.EventSourcing @@ -19,7 +19,8 @@ namespace Squidex.Infrastructure.EventSourcing { public Task CreateIndexAsync(string property) { - return Collection.Indexes.CreateOneAsync(Index.Ascending(CreateIndexPath(property))); + return Collection.Indexes.CreateOneAsync( + new CreateIndexModel(Index.Ascending(CreateIndexPath(property)))); } public IEventSubscription CreateSubscription(IEventSubscriber subscriber, string streamFilter, string position = null) @@ -27,42 +28,45 @@ namespace Squidex.Infrastructure.EventSourcing Guard.NotNull(subscriber, nameof(subscriber)); Guard.NotNullOrEmpty(streamFilter, nameof(streamFilter)); - return new PollingSubscription(this, notifier, subscriber, streamFilter, position); + return new PollingSubscription(this, subscriber, streamFilter, position); } public async Task> QueryAsync(string streamName, long streamPosition = 0) { - var commits = - await Collection.Find( - Filter.And( - Filter.Eq(EventStreamField, streamName), - Filter.Gte(EventStreamOffsetField, streamPosition - 1))) - .Sort(Sort.Ascending(TimestampField)).ToListAsync(); - - var result = new List(); - - foreach (var commit in commits) + using (Profiler.TraceMethod()) { - var eventStreamOffset = (int)commit.EventStreamOffset; + var commits = + await Collection.Find( + Filter.And( + Filter.Eq(EventStreamField, streamName), + Filter.Gte(EventStreamOffsetField, streamPosition - 1))) + .Sort(Sort.Ascending(TimestampField)).ToListAsync(); - var commitTimestamp = commit.Timestamp; - var commitOffset = 0; + var result = new List(); - foreach (var e in commit.Events) + foreach (var commit in commits) { - eventStreamOffset++; + var eventStreamOffset = (int)commit.EventStreamOffset; + + var commitTimestamp = commit.Timestamp; + var commitOffset = 0; - if (eventStreamOffset >= streamPosition) + foreach (var e in commit.Events) { - var eventData = e.ToEventData(); - var eventToken = new StreamPosition(commitTimestamp, commitOffset, commit.Events.Length); + eventStreamOffset++; - result.Add(new StoredEvent(eventToken, eventStreamOffset, eventData)); + if (eventStreamOffset >= streamPosition) + { + var eventData = e.ToEventData(); + var eventToken = new StreamPosition(commitTimestamp, commitOffset, commit.Events.Length); + + result.Add(new StoredEvent(streamName, eventToken, eventStreamOffset, eventData)); + } } } - } - return result; + return result; + } } public Task QueryAsync(Func callback, string property, object value, string position = null, CancellationToken ct = default(CancellationToken)) @@ -89,36 +93,39 @@ namespace Squidex.Infrastructure.EventSourcing private async Task QueryAsync(Func callback, StreamPosition lastPosition, FilterDefinition filter, CancellationToken ct) { - await Collection.Find(filter).Sort(Sort.Ascending(TimestampField)).ForEachAsync(async commit => + using (Profiler.TraceMethod()) { - var eventStreamOffset = (int)commit.EventStreamOffset; - - var commitTimestamp = commit.Timestamp; - var commitOffset = 0; - - foreach (var e in commit.Events) + await Collection.Find(filter).Sort(Sort.Ascending(TimestampField)).ForEachPipelineAsync(async commit => { - eventStreamOffset++; + var eventStreamOffset = (int)commit.EventStreamOffset; + + var commitTimestamp = commit.Timestamp; + var commitOffset = 0; - if (commitOffset > lastPosition.CommitOffset || commitTimestamp > lastPosition.Timestamp) + foreach (var e in commit.Events) { - var eventData = e.ToEventData(); - var eventToken = new StreamPosition(commitTimestamp, commitOffset, commit.Events.Length); + eventStreamOffset++; - await callback(new StoredEvent(eventToken, eventStreamOffset, eventData)); + if (commitOffset > lastPosition.CommitOffset || commitTimestamp > lastPosition.Timestamp) + { + var eventData = e.ToEventData(); + var eventToken = new StreamPosition(commitTimestamp, commitOffset, commit.Events.Length); - commitOffset++; + await callback(new StoredEvent(commit.EventStream, eventToken, eventStreamOffset, eventData)); + + commitOffset++; + } } - } - }, ct); + }, ct); + } } private static FilterDefinition CreateFilter(string property, object value, StreamPosition streamPosition) { var filters = new List>(); - AddPositionFilter(streamPosition, filters); - AddPropertyFitler(property, value, filters); + FilterByPosition(streamPosition, filters); + FilterByProperty(property, value, filters); return Filter.And(filters); } @@ -127,18 +134,18 @@ namespace Squidex.Infrastructure.EventSourcing { var filters = new List>(); - AddPositionFilter(streamPosition, filters); - AddStreamFilter(streamFilter, filters); + FilterByPosition(streamPosition, filters); + FilterByStream(streamFilter, filters); return Filter.And(filters); } - private static void AddPropertyFitler(string property, object value, List> filters) + private static void FilterByProperty(string property, object value, List> filters) { filters.Add(Filter.Eq(CreateIndexPath(property), value)); } - private static void AddStreamFilter(string streamFilter, List> filters) + private static void FilterByStream(string streamFilter, List> filters) { if (!string.IsNullOrWhiteSpace(streamFilter) && !string.Equals(streamFilter, ".*", StringComparison.OrdinalIgnoreCase)) { @@ -153,7 +160,7 @@ namespace Squidex.Infrastructure.EventSourcing } } - private static void AddPositionFilter(StreamPosition streamPosition, List> filters) + private static void FilterByPosition(StreamPosition streamPosition, List> filters) { if (streamPosition.IsEndOfCommit) { diff --git a/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Writer.cs b/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Writer.cs index 937b66050..4e77f4991 100644 --- a/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Writer.cs +++ b/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Writer.cs @@ -7,10 +7,10 @@ using System; using System.Collections.Generic; -using System.Reactive.Linq; using System.Threading.Tasks; using MongoDB.Bson; using MongoDB.Driver; +using Squidex.Infrastructure.Log; namespace Squidex.Infrastructure.EventSourcing { @@ -19,6 +19,16 @@ namespace Squidex.Infrastructure.EventSourcing private const int MaxWriteAttempts = 20; private static readonly BsonTimestamp EmptyTimestamp = new BsonTimestamp(0); + public Task DeleteStreamAsync(string streamName) + { + return Collection.DeleteManyAsync(x => x.EventStream == streamName); + } + + public Task DeleteManyAsync(string property, object value) + { + return Collection.DeleteManyAsync(Filter.Eq(CreateIndexPath(property), value)); + } + public Task AppendAsync(Guid commitId, string streamName, ICollection events) { return AppendAsync(commitId, streamName, EtagVersion.Any, events); @@ -26,58 +36,61 @@ namespace Squidex.Infrastructure.EventSourcing public async Task AppendAsync(Guid commitId, string streamName, long expectedVersion, ICollection events) { - Guard.GreaterEquals(expectedVersion, EtagVersion.Any, nameof(expectedVersion)); - Guard.NotNullOrEmpty(streamName, nameof(streamName)); - Guard.NotNull(events, nameof(events)); - - if (events.Count == 0) + using (Profiler.TraceMethod()) { - return; - } - - var currentVersion = await GetEventStreamOffset(streamName); + Guard.GreaterEquals(expectedVersion, EtagVersion.Any, nameof(expectedVersion)); + Guard.NotNullOrEmpty(streamName, nameof(streamName)); + Guard.NotNull(events, nameof(events)); - if (expectedVersion != EtagVersion.Any && expectedVersion != currentVersion) - { - throw new WrongEventVersionException(currentVersion, expectedVersion); - } + if (events.Count == 0) + { + return; + } - var commit = BuildCommit(commitId, streamName, expectedVersion >= -1 ? expectedVersion : currentVersion, events); + var currentVersion = await GetEventStreamOffset(streamName); - for (var attempt = 0; attempt < MaxWriteAttempts; attempt++) - { - try + if (expectedVersion != EtagVersion.Any && expectedVersion != currentVersion) { - await Collection.InsertOneAsync(commit); + throw new WrongEventVersionException(currentVersion, expectedVersion); + } - notifier.NotifyEventsStored(streamName); + var commit = BuildCommit(commitId, streamName, expectedVersion >= -1 ? expectedVersion : currentVersion, events); - return; - } - catch (MongoWriteException ex) + for (var attempt = 0; attempt < MaxWriteAttempts; attempt++) { - if (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) + try { - currentVersion = await GetEventStreamOffset(streamName); + await Collection.InsertOneAsync(commit); - if (expectedVersion != EtagVersion.Any) - { - throw new WrongEventVersionException(currentVersion, expectedVersion); - } + notifier.NotifyEventsStored(streamName); - if (attempt < MaxWriteAttempts) + return; + } + catch (MongoWriteException ex) + { + if (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) { - expectedVersion = currentVersion; + currentVersion = await GetEventStreamOffset(streamName); + + if (expectedVersion != EtagVersion.Any) + { + throw new WrongEventVersionException(currentVersion, expectedVersion); + } + + if (attempt < MaxWriteAttempts) + { + expectedVersion = currentVersion; + } + else + { + throw new TimeoutException("Could not acquire a free slot for the commit within the provided time."); + } } else { - throw new TimeoutException("Could not acquire a free slot for the commit within the provided time."); + throw; } } - else - { - throw; - } } } } diff --git a/src/Squidex.Infrastructure.MongoDb/MongoDb/BsonJsonConvention.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/BsonJsonConvention.cs index 2a8d6e572..77019a716 100644 --- a/src/Squidex.Infrastructure.MongoDb/MongoDb/BsonJsonConvention.cs +++ b/src/Squidex.Infrastructure.MongoDb/MongoDb/BsonJsonConvention.cs @@ -49,4 +49,4 @@ namespace Squidex.Infrastructure.MongoDb ConventionRegistry.Register("json", pack, t => true); } } -} +} \ No newline at end of file diff --git a/src/Squidex.Infrastructure.MongoDb/MongoDb/BsonJsonConverter.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/BsonJsonConverter.cs index e1e5b84e4..e9aa9d8cc 100644 --- a/src/Squidex.Infrastructure.MongoDb/MongoDb/BsonJsonConverter.cs +++ b/src/Squidex.Infrastructure.MongoDb/MongoDb/BsonJsonConverter.cs @@ -6,6 +6,7 @@ // ========================================================================== using System; +using System.Globalization; using MongoDB.Bson; using Newtonsoft.Json.Linq; @@ -84,22 +85,29 @@ namespace Squidex.Infrastructure.MongoDb case JTokenType.Bytes: return BsonValue.Create(((JValue)source).Value); case JTokenType.Guid: - return BsonValue.Create(((JValue)source).ToString()); + return BsonValue.Create(((JValue)source).ToString(CultureInfo.InvariantCulture)); case JTokenType.Uri: - return BsonValue.Create(((JValue)source).ToString()); + return BsonValue.Create(((JValue)source).ToString(CultureInfo.InvariantCulture)); case JTokenType.TimeSpan: - return BsonValue.Create(((JValue)source).ToString()); + return BsonValue.Create(((JValue)source).ToString(CultureInfo.InvariantCulture)); case JTokenType.Date: { var value = ((JValue)source).Value; if (value is DateTime dateTime) { - return dateTime.ToString("yyyy-MM-ddTHH:mm:ssK"); + return dateTime.ToString("yyyy-MM-ddTHH:mm:ssK", CultureInfo.InvariantCulture); } else if (value is DateTimeOffset dateTimeOffset) { - return dateTimeOffset.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ssK"); + if (dateTimeOffset.Offset == TimeSpan.Zero) + { + return dateTimeOffset.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ssK", CultureInfo.InvariantCulture); + } + else + { + return dateTimeOffset.ToString("yyyy-MM-ddTHH:mm:ssK", CultureInfo.InvariantCulture); + } } else { diff --git a/src/Squidex.Infrastructure.MongoDb/MongoDb/BsonJsonSerializer.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/BsonJsonSerializer.cs index d06df02c9..eefb3e3e5 100644 --- a/src/Squidex.Infrastructure.MongoDb/MongoDb/BsonJsonSerializer.cs +++ b/src/Squidex.Infrastructure.MongoDb/MongoDb/BsonJsonSerializer.cs @@ -12,7 +12,7 @@ using Newtonsoft.Json; namespace Squidex.Infrastructure.MongoDb { - public class BsonJsonSerializer : ClassSerializerBase where T : class + public sealed class BsonJsonSerializer : ClassSerializerBase where T : class { private readonly JsonSerializer serializer; diff --git a/src/Squidex.Infrastructure.MongoDb/MongoDb/BsonJsonWriter.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/BsonJsonWriter.cs index dcf598616..558970951 100644 --- a/src/Squidex.Infrastructure.MongoDb/MongoDb/BsonJsonWriter.cs +++ b/src/Squidex.Infrastructure.MongoDb/MongoDb/BsonJsonWriter.cs @@ -6,6 +6,7 @@ // ========================================================================== using System; +using System.Globalization; using MongoDB.Bson.IO; using NewtonsoftJSonWriter = Newtonsoft.Json.JsonWriter; @@ -134,12 +135,19 @@ namespace Squidex.Infrastructure.MongoDb public override void WriteValue(DateTime value) { - bsonWriter.WriteString(value.ToString()); + bsonWriter.WriteString(value.ToString("yyyy-MM-ddTHH:mm:ssK", CultureInfo.InvariantCulture)); } public override void WriteValue(DateTimeOffset value) { - bsonWriter.WriteString(value.ToString()); + if (value.Offset == TimeSpan.Zero) + { + bsonWriter.WriteString(value.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ssK", CultureInfo.InvariantCulture)); + } + else + { + bsonWriter.WriteString(value.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ssK", CultureInfo.InvariantCulture)); + } } public override void WriteValue(byte[] value) diff --git a/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoExtensions.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoExtensions.cs index 1db3eff76..3681a4aa7 100644 --- a/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoExtensions.cs +++ b/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoExtensions.cs @@ -7,7 +7,9 @@ using System; using System.Linq.Expressions; +using System.Threading; using System.Threading.Tasks; +using System.Threading.Tasks.Dataflow; using MongoDB.Bson; using MongoDB.Driver; using Squidex.Infrastructure.States; @@ -72,6 +74,27 @@ namespace Squidex.Infrastructure.MongoDb return find.Project(Builders.Projection.Include(include1).Include(include2).Include(include3)); } + public static IFindFluent Not(this IFindFluent find, + Expression> exclude) + { + return find.Project(Builders.Projection.Exclude(exclude)); + } + + public static IFindFluent Not(this IFindFluent find, + Expression> exclude1, + Expression> exclude2) + { + return find.Project(Builders.Projection.Exclude(exclude1).Exclude(exclude2)); + } + + public static IFindFluent Not(this IFindFluent find, + Expression> exclude1, + Expression> exclude2, + Expression> exclude3) + { + return find.Project(Builders.Projection.Exclude(exclude1).Exclude(exclude2).Exclude(exclude3)); + } + public static async Task UpsertVersionedAsync(this IMongoCollection collection, TKey key, long oldVersion, long newVersion, Func, UpdateDefinition> updater) where T : IVersionedEntity { try @@ -128,5 +151,56 @@ namespace Squidex.Infrastructure.MongoDb } } } + + public static async Task ForEachPipelineAsync(this IAsyncCursorSource source, Func processor, CancellationToken cancellationToken = default(CancellationToken)) + { + var cursor = await source.ToCursorAsync(cancellationToken); + + await cursor.ForEachPipelineAsync(processor, cancellationToken); + } + + public static async Task ForEachPipelineAsync(this IAsyncCursor source, Func processor, CancellationToken cancellationToken = default(CancellationToken)) + { + using (var selfToken = new CancellationTokenSource()) + { + using (var combined = CancellationTokenSource.CreateLinkedTokenSource(selfToken.Token, cancellationToken)) + { + var actionBlock = + new ActionBlock(async x => + { + if (!combined.IsCancellationRequested) + { + await processor(x); + } + }, + new ExecutionDataflowBlockOptions + { + MaxDegreeOfParallelism = 1, + MaxMessagesPerTask = 1, + BoundedCapacity = 100 + }); + try + { + await source.ForEachAsync(async i => + { + if (!await actionBlock.SendAsync(i, combined.Token)) + { + selfToken.Cancel(); + } + }, combined.Token); + + actionBlock.Complete(); + } + catch (Exception ex) + { + ((IDataflowBlock)actionBlock).Fault(ex); + } + finally + { + await actionBlock.Completion; + } + } + } + } } } diff --git a/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/ConstantVisitor.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/ConstantVisitor.cs deleted file mode 100644 index 482d6a7c1..000000000 --- a/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/ConstantVisitor.cs +++ /dev/null @@ -1,65 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using Microsoft.OData.Edm; -using Microsoft.OData.UriParser; -using NodaTime; -using NodaTime.Text; - -namespace Squidex.Infrastructure.MongoDb.OData -{ - public sealed class ConstantVisitor : QueryNodeVisitor - { - private static readonly IEdmPrimitiveType BooleanType = EdmCoreModel.Instance.GetPrimitiveType(EdmPrimitiveTypeKind.Boolean); - private static readonly IEdmPrimitiveType DateTimeType = EdmCoreModel.Instance.GetPrimitiveType(EdmPrimitiveTypeKind.DateTimeOffset); - private static readonly IEdmPrimitiveType GuidType = EdmCoreModel.Instance.GetPrimitiveType(EdmPrimitiveTypeKind.Guid); - - private static readonly ConstantVisitor Instance = new ConstantVisitor(); - - private ConstantVisitor() - { - } - - public static object Visit(QueryNode node) - { - return node.Accept(Instance); - } - - public override object Visit(ConvertNode nodeIn) - { - if (nodeIn.TypeReference.Definition == BooleanType) - { - return bool.Parse(Visit(nodeIn.Source).ToString()); - } - - if (nodeIn.TypeReference.Definition == GuidType) - { - return Guid.Parse(Visit(nodeIn.Source).ToString()); - } - - if (nodeIn.TypeReference.Definition == DateTimeType) - { - var value = Visit(nodeIn.Source); - - if (value is DateTimeOffset dateTimeOffset) - { - return Instant.FromDateTimeOffset(dateTimeOffset); - } - - return InstantPattern.General.Parse(Visit(nodeIn.Source).ToString()).Value; - } - - return base.Visit(nodeIn); - } - - public override object Visit(ConstantNode nodeIn) - { - return nodeIn.Value; - } - } -} diff --git a/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/FilterBuilder.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/FilterBuilder.cs index ffc82c1dc..4b9079327 100644 --- a/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/FilterBuilder.cs +++ b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/FilterBuilder.cs @@ -5,49 +5,28 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Microsoft.OData; -using Microsoft.OData.UriParser; using MongoDB.Driver; +using Squidex.Infrastructure.Queries; namespace Squidex.Infrastructure.MongoDb.OData { public static class FilterBuilder { - public static (FilterDefinition Filter, bool Last) BuildFilter(this ODataUriParser query, PropertyCalculator propertyCalculator = null, bool supportsSearch = true) + public static (FilterDefinition Filter, bool Last) BuildFilter(this Query query, bool supportsSearch = true) { - SearchClause search; - try - { - search = query.ParseSearch(); - } - catch (ODataException ex) - { - throw new ValidationException("Query $search clause not valid.", new ValidationError(ex.Message)); - } - - if (search != null) + if (query.FullText != null) { if (!supportsSearch) { throw new ValidationException("Query $search clause not supported."); } - return (Builders.Filter.Text(SearchTermVisitor.Visit(search.Expression).ToString()), false); - } - - FilterClause filter; - try - { - filter = query.ParseFilter(); - } - catch (ODataException ex) - { - throw new ValidationException("Query $filter clause not valid.", new ValidationError(ex.Message)); + return (Builders.Filter.Text(query.FullText), false); } - if (filter != null) + if (query.Filter != null) { - return (FilterVisitor.Visit(filter.Expression, propertyCalculator), true); + return (FilterVisitor.Visit(query.Filter), true); } return (null, false); diff --git a/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/FilterVisitor.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/FilterVisitor.cs index cfe498c4e..c40733f6b 100644 --- a/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/FilterVisitor.cs +++ b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/FilterVisitor.cs @@ -6,156 +6,79 @@ // ========================================================================== using System; +using System.Collections; using System.Linq; -using Microsoft.OData.UriParser; using MongoDB.Bson; using MongoDB.Driver; +using Squidex.Infrastructure.Queries; namespace Squidex.Infrastructure.MongoDb.OData { - public sealed class FilterVisitor : QueryNodeVisitor> + public sealed class FilterVisitor : FilterNodeVisitor> { private static readonly FilterDefinitionBuilder Filter = Builders.Filter; - private readonly PropertyCalculator propertyCalculator; + private static readonly FilterVisitor Instance = new FilterVisitor(); - private FilterVisitor(PropertyCalculator propertyCalculator) + private FilterVisitor() { - this.propertyCalculator = propertyCalculator; } - public static FilterDefinition Visit(QueryNode node, PropertyCalculator propertyCalculator) + public static FilterDefinition Visit(FilterNode node) { - var visitor = new FilterVisitor(propertyCalculator); - - return node.Accept(visitor); + return node.Accept(Instance); } - public override FilterDefinition Visit(ConvertNode nodeIn) + public override FilterDefinition Visit(FilterNegate nodeIn) { - return nodeIn.Source.Accept(this); + return Filter.Not(nodeIn.Operand.Accept(this)); } - public override FilterDefinition Visit(UnaryOperatorNode nodeIn) + public override FilterDefinition Visit(FilterJunction nodeIn) { - if (nodeIn.OperatorKind == UnaryOperatorKind.Not) + if (nodeIn.JunctionType == FilterJunctionType.And) { - return Filter.Not(nodeIn.Operand.Accept(this)); + return Filter.And(nodeIn.Operands.Select(x => x.Accept(this))); } - - throw new NotSupportedException(); - } - - public override FilterDefinition Visit(SingleValueFunctionCallNode nodeIn) - { - var fieldNode = nodeIn.Parameters.ElementAt(0); - var valueNode = nodeIn.Parameters.ElementAt(1); - - if (string.Equals(nodeIn.Name, "endswith", StringComparison.OrdinalIgnoreCase)) - { - var value = BuildRegex(valueNode, v => v + "$"); - - return Filter.Regex(BuildFieldDefinition(fieldNode), value); - } - - if (string.Equals(nodeIn.Name, "startswith", StringComparison.OrdinalIgnoreCase)) - { - var value = BuildRegex(valueNode, v => "^" + v); - - return Filter.Regex(BuildFieldDefinition(fieldNode), value); - } - - if (string.Equals(nodeIn.Name, "contains", StringComparison.OrdinalIgnoreCase)) + else { - var value = BuildRegex(valueNode, v => v); - - return Filter.Regex(BuildFieldDefinition(fieldNode), value); + return Filter.Or(nodeIn.Operands.Select(x => x.Accept(this))); } - - throw new NotSupportedException(); } - public override FilterDefinition Visit(BinaryOperatorNode nodeIn) + public override FilterDefinition Visit(FilterComparison nodeIn) { - if (nodeIn.OperatorKind == BinaryOperatorKind.And) - { - return Filter.And(nodeIn.Left.Accept(this), nodeIn.Right.Accept(this)); - } + var propertyName = string.Join(".", nodeIn.Lhs); - if (nodeIn.OperatorKind == BinaryOperatorKind.Or) + switch (nodeIn.Operator) { - return Filter.Or(nodeIn.Left.Accept(this), nodeIn.Right.Accept(this)); - } - - if (nodeIn.Left is SingleValueFunctionCallNode functionNode) - { - var regexFilter = Visit(functionNode); - - var value = BuildValue(nodeIn.Right); - - if (value is bool booleanRight) - { - if ((nodeIn.OperatorKind == BinaryOperatorKind.Equal && !booleanRight) || - (nodeIn.OperatorKind == BinaryOperatorKind.NotEqual && booleanRight)) - { - regexFilter = Filter.Not(regexFilter); - } - - return regexFilter; - } - } - else - { - if (nodeIn.OperatorKind == BinaryOperatorKind.NotEqual) - { - var field = BuildFieldDefinition(nodeIn.Left); - - return Filter.Or( - Filter.Not(Filter.Exists(field)), - Filter.Ne(field, BuildValue(nodeIn.Right))); - } - - if (nodeIn.OperatorKind == BinaryOperatorKind.Equal) - { - return Filter.Eq(BuildFieldDefinition(nodeIn.Left), BuildValue(nodeIn.Right)); - } - - if (nodeIn.OperatorKind == BinaryOperatorKind.LessThan) - { - return Filter.Lt(BuildFieldDefinition(nodeIn.Left), BuildValue(nodeIn.Right)); - } - - if (nodeIn.OperatorKind == BinaryOperatorKind.LessThanOrEqual) - { - return Filter.Lte(BuildFieldDefinition(nodeIn.Left), BuildValue(nodeIn.Right)); - } - - if (nodeIn.OperatorKind == BinaryOperatorKind.GreaterThan) - { - return Filter.Gt(BuildFieldDefinition(nodeIn.Left), BuildValue(nodeIn.Right)); - } - - if (nodeIn.OperatorKind == BinaryOperatorKind.GreaterThanOrEqual) - { - return Filter.Gte(BuildFieldDefinition(nodeIn.Left), BuildValue(nodeIn.Right)); - } + case FilterOperator.StartsWith: + return Filter.Regex(propertyName, BuildRegex(nodeIn, s => "$" + s)); + case FilterOperator.Contains: + return Filter.Regex(propertyName, BuildRegex(nodeIn, s => s)); + case FilterOperator.EndsWith: + return Filter.Regex(propertyName, BuildRegex(nodeIn, s => s + "$")); + case FilterOperator.Equals: + return Filter.Eq(propertyName, nodeIn.Rhs.Value); + case FilterOperator.GreaterThan: + return Filter.Gt(propertyName, nodeIn.Rhs.Value); + case FilterOperator.GreaterThanOrEqual: + return Filter.Gte(propertyName, nodeIn.Rhs.Value); + case FilterOperator.LessThan: + return Filter.Lt(propertyName, nodeIn.Rhs.Value); + case FilterOperator.LessThanOrEqual: + return Filter.Lte(propertyName, nodeIn.Rhs.Value); + case FilterOperator.NotEquals: + return Filter.Ne(propertyName, nodeIn.Rhs.Value); + case FilterOperator.In: + return Filter.In(propertyName, ((IList)nodeIn.Rhs.Value).OfType()); } throw new NotSupportedException(); } - private static BsonRegularExpression BuildRegex(QueryNode node, Func formatter) - { - return new BsonRegularExpression(formatter(BuildValue(node).ToString()), "i"); - } - - private FieldDefinition BuildFieldDefinition(QueryNode nodeIn) - { - return nodeIn.BuildFieldDefinition(propertyCalculator); - } - - private static object BuildValue(QueryNode nodeIn) + private static BsonRegularExpression BuildRegex(FilterComparison node, Func formatter) { - return ConstantVisitor.Visit(nodeIn); + return new BsonRegularExpression(formatter(node.Rhs.Value.ToString()), "i"); } } } diff --git a/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/LimitExtensions.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/LimitExtensions.cs index 8296a4476..9d07bc985 100644 --- a/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/LimitExtensions.cs +++ b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/LimitExtensions.cs @@ -5,41 +5,28 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; -using Microsoft.OData.UriParser; using MongoDB.Driver; +using Squidex.Infrastructure.Queries; namespace Squidex.Infrastructure.MongoDb.OData { public static class LimitExtensions { - public static IFindFluent Take(this IFindFluent cursor, ODataUriParser query, int maxValue = 200, int defaultValue = 20) + public static IFindFluent Take(this IFindFluent cursor, Query query) { - var top = query.ParseTop(); - - if (top.HasValue) - { - cursor = cursor.Limit(Math.Min((int)top.Value, maxValue)); - } - else + if (query.Take < long.MaxValue) { - cursor = cursor.Limit(defaultValue); + cursor = cursor.Limit((int)query.Take); } return cursor; } - public static IFindFluent Skip(this IFindFluent cursor, ODataUriParser query) + public static IFindFluent Skip(this IFindFluent cursor, Query query) { - var skip = query.ParseSkip(); - - if (skip.HasValue) - { - cursor = cursor.Skip((int)skip.Value); - } - else + if (query.Skip > 0) { - cursor = cursor.Skip(null); + cursor = cursor.Skip((int)query.Skip); } return cursor; diff --git a/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/PropertyBuilder.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/PropertyBuilder.cs deleted file mode 100644 index ede103c64..000000000 --- a/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/PropertyBuilder.cs +++ /dev/null @@ -1,33 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Linq; -using Microsoft.OData.UriParser; -using MongoDB.Driver; - -namespace Squidex.Infrastructure.MongoDb.OData -{ - public delegate string PropertyCalculator(string[] parts); - - public static class PropertyBuilder - { - private static readonly PropertyCalculator DefaultCalculator = parts => - { - return string.Join(".", parts).ToPascalCase(); - }; - - public static StringFieldDefinition BuildFieldDefinition(this QueryNode node, PropertyCalculator propertyCalculator) - { - propertyCalculator = propertyCalculator ?? DefaultCalculator; - - var propertyParts = node.Accept(PropertyNameVisitor.Instance).ToArray(); - var propertyName = propertyCalculator(propertyParts); - - return new StringFieldDefinition(propertyName); - } - } -} diff --git a/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/PropertyNameVisitor.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/PropertyNameVisitor.cs deleted file mode 100644 index 116fc4e98..000000000 --- a/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/PropertyNameVisitor.cs +++ /dev/null @@ -1,50 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Collections.Immutable; -using Microsoft.OData.UriParser; - -namespace Squidex.Infrastructure.MongoDb.OData -{ - public sealed class PropertyNameVisitor : QueryNodeVisitor> - { - public static readonly PropertyNameVisitor Instance = new PropertyNameVisitor(); - - private PropertyNameVisitor() - { - } - - public override ImmutableList Visit(ConvertNode nodeIn) - { - return nodeIn.Source.Accept(this); - } - - public override ImmutableList Visit(SingleComplexNode nodeIn) - { - if (nodeIn.Source is SingleComplexNode) - { - return nodeIn.Source.Accept(this).Add(nodeIn.Property.Name); - } - else - { - return ImmutableList.Create(nodeIn.Property.Name); - } - } - - public override ImmutableList Visit(SingleValuePropertyAccessNode nodeIn) - { - if (nodeIn.Source is SingleComplexNode) - { - return nodeIn.Source.Accept(this).Add(nodeIn.Property.Name); - } - else - { - return ImmutableList.Create(nodeIn.Property.Name); - } - } - } -} diff --git a/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/SearchTermVisitor.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/SearchTermVisitor.cs deleted file mode 100644 index 85f897c80..000000000 --- a/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/SearchTermVisitor.cs +++ /dev/null @@ -1,41 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using Microsoft.OData.UriParser; - -namespace Squidex.Infrastructure.MongoDb.OData -{ - public class SearchTermVisitor : QueryNodeVisitor - { - private static readonly SearchTermVisitor Instance = new SearchTermVisitor(); - - private SearchTermVisitor() - { - } - - public static object Visit(QueryNode node) - { - return node.Accept(Instance); - } - - public override string Visit(BinaryOperatorNode nodeIn) - { - if (nodeIn.OperatorKind == BinaryOperatorKind.And) - { - return nodeIn.Left.Accept(this) + " " + nodeIn.Right.Accept(this); - } - - throw new NotSupportedException(); - } - - public override string Visit(SearchTermNode nodeIn) - { - return nodeIn.Text; - } - } -} diff --git a/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/SortBuilder.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/SortBuilder.cs index c19ca4305..8214eb795 100644 --- a/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/SortBuilder.cs +++ b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/SortBuilder.cs @@ -6,26 +6,22 @@ // ========================================================================== using System.Collections.Generic; -using Microsoft.OData.UriParser; using MongoDB.Driver; +using Squidex.Infrastructure.Queries; namespace Squidex.Infrastructure.MongoDb.OData { public static class SortBuilder { - public static SortDefinition BuildSort(this ODataUriParser query, PropertyCalculator propertyCalculator = null) + public static SortDefinition BuildSort(this Query query) { - var orderBy = query.ParseOrderBy(); - - if (orderBy != null) + if (query.Sort.Count > 0) { var sorts = new List>(); - while (orderBy != null) + foreach (var sort in query.Sort) { - sorts.Add(OrderBy(orderBy, propertyCalculator)); - - orderBy = orderBy.ThenBy; + sorts.Add(OrderBy(sort)); } if (sorts.Count > 1) @@ -41,11 +37,11 @@ namespace Squidex.Infrastructure.MongoDb.OData return null; } - public static SortDefinition OrderBy(OrderByClause clause, PropertyCalculator propertyCalculator = null) + public static SortDefinition OrderBy(SortNode sort) { - var propertyName = clause.Expression.BuildFieldDefinition(propertyCalculator); + var propertyName = string.Join(".", sort.Path); - if (clause.Direction == OrderByDirection.Ascending) + if (sort.SortOrder == SortOrder.Ascending) { return Builders.Sort.Ascending(propertyName); } diff --git a/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj b/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj index 43fd73478..e158851ac 100644 --- a/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj +++ b/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj @@ -11,11 +11,12 @@ - - - - - + + + + + + ..\..\Squidex.ruleset diff --git a/src/Squidex.Infrastructure.MongoDb/States/MongoSnapshotStore.cs b/src/Squidex.Infrastructure.MongoDb/States/MongoSnapshotStore.cs index c90506746..d4c7a0b8c 100644 --- a/src/Squidex.Infrastructure.MongoDb/States/MongoSnapshotStore.cs +++ b/src/Squidex.Infrastructure.MongoDb/States/MongoSnapshotStore.cs @@ -5,47 +5,70 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Linq; using System.Threading.Tasks; +using MongoDB.Bson; using MongoDB.Driver; -using Newtonsoft.Json; +using Squidex.Infrastructure.Log; using Squidex.Infrastructure.MongoDb; namespace Squidex.Infrastructure.States { - public class MongoSnapshotStore : MongoRepositoryBase>, ISnapshotStore, IInitializable + public class MongoSnapshotStore : MongoRepositoryBase>, ISnapshotStore { - private readonly JsonSerializer serializer; - - public MongoSnapshotStore(IMongoDatabase database, JsonSerializer serializer) + public MongoSnapshotStore(IMongoDatabase database) : base(database) { - Guard.NotNull(serializer, nameof(serializer)); - - this.serializer = serializer; } protected override string CollectionName() { - return $"States_{typeof(T).Name}"; + var attribute = typeof(T).GetCustomAttributes(true).OfType().FirstOrDefault(); + + var name = attribute?.Name ?? typeof(T).Name; + + return $"States_{name}"; } public async Task<(T Value, long Version)> ReadAsync(TKey key) { - var existing = - await Collection.Find(x => Equals(x.Id, key)) - .FirstOrDefaultAsync(); + using (Profiler.TraceMethod>()) + { + var existing = + await Collection.Find(x => x.Id.Equals(key)) + .FirstOrDefaultAsync(); + + if (existing != null) + { + return (existing.Doc, existing.Version); + } + + return (default(T), EtagVersion.NotFound); + } + } - if (existing != null) + public async Task WriteAsync(TKey key, T value, long oldVersion, long newVersion) + { + using (Profiler.TraceMethod>()) { - return (existing.Doc, existing.Version); + await Collection.UpsertVersionedAsync(key, oldVersion, newVersion, u => u.Set(x => x.Doc, value)); } + } - return (default(T), EtagVersion.NotFound); + public async Task ReadAllAsync(System.Func callback) + { + using (Profiler.TraceMethod>()) + { + await Collection.Find(new BsonDocument()).ForEachAsync(x => callback(x.Doc, x.Version)); + } } - public Task WriteAsync(TKey key, T value, long oldVersion, long newVersion) + public async Task RemoveAsync(TKey key) { - return Collection.UpsertVersionedAsync(key, oldVersion, newVersion, u => u.Set(x => x.Doc, value)); + using (Profiler.TraceMethod>()) + { + await Collection.DeleteOneAsync(x => x.Id.Equals(key)); + } } } } diff --git a/src/Squidex.Infrastructure.MongoDb/States/MongoState.cs b/src/Squidex.Infrastructure.MongoDb/States/MongoState.cs index f85bf59ab..bbea29936 100644 --- a/src/Squidex.Infrastructure.MongoDb/States/MongoState.cs +++ b/src/Squidex.Infrastructure.MongoDb/States/MongoState.cs @@ -11,6 +11,7 @@ using Squidex.Infrastructure.MongoDb; namespace Squidex.Infrastructure.States { + [BsonIgnoreExtraElements] public sealed class MongoState : IVersionedEntity { [BsonId] diff --git a/src/Squidex.Infrastructure.MongoDb/UsageTracking/MongoUsage.cs b/src/Squidex.Infrastructure.MongoDb/UsageTracking/MongoUsage.cs index 1fdd1843f..9d64092e6 100644 --- a/src/Squidex.Infrastructure.MongoDb/UsageTracking/MongoUsage.cs +++ b/src/Squidex.Infrastructure.MongoDb/UsageTracking/MongoUsage.cs @@ -27,6 +27,10 @@ namespace Squidex.Infrastructure.UsageTracking [BsonElement] public string Key { get; set; } + [BsonIgnoreIfNull] + [BsonElement] + public string Category { get; set; } + [BsonRequired] [BsonElement] public double TotalCount { get; set; } diff --git a/src/Squidex.Infrastructure.MongoDb/UsageTracking/MongoUsageStore.cs b/src/Squidex.Infrastructure.MongoDb/UsageTracking/MongoUsageStore.cs index fb550d183..dc77f5677 100644 --- a/src/Squidex.Infrastructure.MongoDb/UsageTracking/MongoUsageStore.cs +++ b/src/Squidex.Infrastructure.MongoDb/UsageTracking/MongoUsageStore.cs @@ -28,20 +28,22 @@ namespace Squidex.Infrastructure.UsageTracking protected override Task SetupCollectionAsync(IMongoCollection collection) { - return collection.Indexes.CreateOneAsync(Index.Ascending(x => x.Key).Ascending(x => x.Date)); + return collection.Indexes.CreateOneAsync( + new CreateIndexModel(Index.Ascending(x => x.Key).Ascending(x => x.Category).Ascending(x => x.Date))); } - public Task TrackUsagesAsync(DateTime date, string key, double count, double elapsedMs) + public Task TrackUsagesAsync(DateTime date, string key, string category, double count, double elapsedMs) { - var id = $"{key}_{date:yyyy-MM-dd}"; + var id = $"{key}_{date:yyyy-MM-dd}_{category}"; - return Collection.UpdateOneAsync(x => x.Id == id, + return Collection.UpdateOneAsync(x => x.Id == id && x.Category == category, Update .Inc(x => x.TotalCount, count) .Inc(x => x.TotalElapsedMs, elapsedMs) .SetOnInsert(x => x.Id, id) .SetOnInsert(x => x.Key, key) - .SetOnInsert(x => x.Date, date), + .SetOnInsert(x => x.Date, date) + .SetOnInsert(x => x.Category, category), Upsert); } @@ -49,7 +51,7 @@ namespace Squidex.Infrastructure.UsageTracking { var entities = await Collection.Find(x => x.Key == key && x.Date >= fromDate && x.Date <= toDate).ToListAsync(); - return entities.Select(x => new StoredUsage(x.Date, (long)x.TotalCount, (long)x.TotalElapsedMs)).ToList(); + return entities.Select(x => new StoredUsage(x.Category, x.Date, (long)x.TotalCount, (long)x.TotalElapsedMs)).ToList(); } } } diff --git a/src/Squidex.Infrastructure.MongoGridFs/Assets/MongoGridFsAssetStore.cs b/src/Squidex.Infrastructure.MongoGridFs/Assets/MongoGridFsAssetStore.cs new file mode 100644 index 000000000..7797ec56d --- /dev/null +++ b/src/Squidex.Infrastructure.MongoGridFs/Assets/MongoGridFsAssetStore.cs @@ -0,0 +1,235 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; +using System.Linq; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using MongoDB.Driver; +using MongoDB.Driver.Core.Bindings; +using MongoDB.Driver.GridFS; + +namespace Squidex.Infrastructure.Assets +{ + public class MongoGridFsAssetStore : IAssetStore, IInitializable + { + public const int ChunkSizeBytes = 255 * 1024; + private const int BufferSize = 81920; + + private readonly string path; + private readonly IGridFSBucket bucket; + private readonly DirectoryInfo directory; + + public MongoGridFsAssetStore(IGridFSBucket bucket, string path) + { + Guard.NotNull(bucket, nameof(bucket)); + Guard.NotNullOrEmpty(path, nameof(path)); + + this.bucket = bucket; + this.path = path; + + directory = new DirectoryInfo(path); + } + + public void Initialize() + { + try + { + // test bucket + bucket.Database.ListCollections(); + + if (!directory.Exists) + { + directory.Create(); + } + } + catch (MongoException ex) + { + throw new ConfigurationException( + $"Cannot connect to Mongo GridFS bucket '${bucket.Options.BucketName}'.", ex); + } + catch (IOException ex) + { + if (!directory.Exists) + { + throw new ConfigurationException($"Cannot access directory '{directory.FullName}'", ex); + } + } + } + + public string GenerateSourceUrl(string id, long version, string suffix) + { + var file = GetFile(id, version, suffix); + + return file.FullName; + } + + public async Task CopyAsync(string name, string id, long version, string suffix, + CancellationToken ct = default(CancellationToken)) + { + try + { + var file = GetFile(name); + var toFile = GetFile(id, version, suffix); + + file.CopyTo(toFile.FullName); + + using (var readStream = await bucket.OpenDownloadStreamAsync(file.Name, cancellationToken: ct)) + { + using (var writeStream = + await bucket.OpenUploadStreamAsync(toFile.Name, toFile.Name, cancellationToken: ct)) + { + var buffer = new byte[ChunkSizeBytes]; + int bytesRead; + while ((bytesRead = await readStream.ReadAsync(buffer, 0, buffer.Length, ct)) > 0) + { + await writeStream.WriteAsync(buffer, 0, bytesRead, ct); + } + + await writeStream.CloseAsync(ct); + } + } + } + catch (FileNotFoundException ex) + { + throw new AssetNotFoundException($"Asset {name} not found.", ex); + } + catch (GridFSException ex) + { + throw new AssetNotFoundException($"Asset {name} not found.", ex); + } + } + + public async Task DownloadAsync(string id, long version, string suffix, Stream stream, + CancellationToken ct = default(CancellationToken)) + { + var file = GetFile(id, version, suffix); + + try + { + if (file.Exists) + { + using (var fileStream = file.OpenRead()) + { + await fileStream.CopyToAsync(stream, BufferSize, ct); + } + } + else + { + // file not found locally + // read from GridFS + using (var readStream = await bucket.OpenDownloadStreamAsync(file.Name, cancellationToken: ct)) + { + using (var fileStream = file.OpenWrite()) + { + var buffer = new byte[BufferSize]; + int bytesRead; + while ((bytesRead = await readStream.ReadAsync(buffer, 0, buffer.Length, ct)) > 0) + { + await fileStream.WriteAsync(buffer, 0, bytesRead, ct); + await stream.WriteAsync(buffer, 0, bytesRead, ct); + } + } + } + } + } + catch (Exception ex) + { + throw new AssetNotFoundException($"Asset {id}, {version} not found.", ex); + } + } + + public Task UploadAsync(string name, Stream stream, CancellationToken ct = default(CancellationToken)) + => UploadFileCoreAsync(GetFile(name), stream, ct); + + public Task UploadAsync(string id, long version, string suffix, Stream stream, + CancellationToken ct = default(CancellationToken)) + => UploadFileCoreAsync(GetFile(id, version, suffix), stream, ct); + + public Task DeleteAsync(string name) + => DeleteCoreAsync(GetFile(name)); + + public Task DeleteAsync(string id, long version, string suffix) + => DeleteCoreAsync(GetFile(id, version, suffix)); + + private async Task DeleteCoreAsync(FileInfo file, CancellationToken ct = default(CancellationToken)) + { + try + { + file.Delete(); + await bucket.DeleteAsync(file.Name, ct); + } + catch (FileNotFoundException ex) + { + throw new AssetNotFoundException($"Asset {file.Name} not found.", ex); + } + catch (GridFSException ex) + { + throw new GridFSException( + $"Cannot delete file {file.Name} into Mongo GridFS bucket '{bucket.Options.BucketName}'.", ex); + } + } + + private async Task UploadFileCoreAsync(FileInfo file, Stream stream, + CancellationToken ct = default(CancellationToken)) + { + try + { + // upload file to GridFS first + await bucket.UploadFromStreamAsync(file.Name, file.Name, stream, cancellationToken: ct); + + // reset stream position + stream.Position = 0; + + // create file locally + // even if this stage will fail, file will be recreated on the next Download call + using (var fileStream = file.OpenWrite()) + { + await stream.CopyToAsync(fileStream, BufferSize, ct); + } + } + catch (IOException ex) + { + throw new IOException($"Cannot write file '{file.Name}' into directory '{directory.FullName}'.", ex); + } + catch (GridFSException ex) + { + throw new GridFSException( + $"Cannot upload file {file.Name} into Mongo GridFS bucket '{bucket.Options.BucketName}'.", + ex); + } + } + + private FileInfo GetFile(string id, long version, string suffix) + { + Guard.NotNullOrEmpty(id, nameof(id)); + + return GetFile(GetPath(id, version, suffix)); + } + + private FileInfo GetFile(string name) + { + Guard.NotNullOrEmpty(name, nameof(name)); + + return new FileInfo(GetPath(name)); + } + + private string GetPath(string name) + { + return Path.Combine(directory.FullName, name); + } + + private string GetPath(string id, long version, string suffix) + { + return Path.Combine(directory.FullName, + string.Join("_", + new[] { id, version.ToString(), suffix }.ToList().Where(x => !string.IsNullOrWhiteSpace(x)))); + } + } +} \ No newline at end of file diff --git a/src/Squidex.Infrastructure.MongoGridFs/Squidex.Infrastructure.MongoGridFs.csproj b/src/Squidex.Infrastructure.MongoGridFs/Squidex.Infrastructure.MongoGridFs.csproj new file mode 100644 index 000000000..a4e3e2f65 --- /dev/null +++ b/src/Squidex.Infrastructure.MongoGridFs/Squidex.Infrastructure.MongoGridFs.csproj @@ -0,0 +1,25 @@ + + + netstandard2.0 + Squidex.Infrastructure + + + full + True + + + + + + + + + ..\..\Squidex.ruleset + + + + + + + + \ No newline at end of file diff --git a/src/Squidex.Infrastructure.RabbitMq/CQRS/Events/RabbitMqEventConsumer.cs b/src/Squidex.Infrastructure.RabbitMq/CQRS/Events/RabbitMqEventConsumer.cs index a33c28cc9..979a66208 100644 --- a/src/Squidex.Infrastructure.RabbitMq/CQRS/Events/RabbitMqEventConsumer.cs +++ b/src/Squidex.Infrastructure.RabbitMq/CQRS/Events/RabbitMqEventConsumer.cs @@ -10,9 +10,10 @@ using System.Text; using System.Threading.Tasks; using Newtonsoft.Json; using RabbitMQ.Client; +using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.Tasks; -namespace Squidex.Infrastructure.EventSourcing +namespace Squidex.Infrastructure.CQRS.Events { public sealed class RabbitMqEventConsumer : DisposableObjectBase, IInitializable, IEventConsumer { diff --git a/src/Squidex.Infrastructure.RabbitMq/Squidex.Infrastructure.RabbitMq.csproj b/src/Squidex.Infrastructure.RabbitMq/Squidex.Infrastructure.RabbitMq.csproj index 043f04732..2965496c8 100644 --- a/src/Squidex.Infrastructure.RabbitMq/Squidex.Infrastructure.RabbitMq.csproj +++ b/src/Squidex.Infrastructure.RabbitMq/Squidex.Infrastructure.RabbitMq.csproj @@ -8,10 +8,10 @@ True - - - - + + + + diff --git a/src/Squidex.Infrastructure.Redis/RedisPubSub.cs b/src/Squidex.Infrastructure.Redis/RedisPubSub.cs index 353f7f295..ce14d18fb 100644 --- a/src/Squidex.Infrastructure.Redis/RedisPubSub.cs +++ b/src/Squidex.Infrastructure.Redis/RedisPubSub.cs @@ -56,7 +56,7 @@ namespace Squidex.Infrastructure { var typeName = typeof(T).FullName; - return (RedisSubscription)subscriptions.GetOrAdd(typeName, c => new RedisSubscription(redisSubscriber.Value, c, log)); + return (RedisSubscription)subscriptions.GetOrAdd(typeName, this, (k, c) => new RedisSubscription(c.redisSubscriber.Value, k, c.log)); } } } diff --git a/src/Squidex.Infrastructure.Redis/Squidex.Infrastructure.Redis.csproj b/src/Squidex.Infrastructure.Redis/Squidex.Infrastructure.Redis.csproj index fee1b24ed..d9948ae88 100644 --- a/src/Squidex.Infrastructure.Redis/Squidex.Infrastructure.Redis.csproj +++ b/src/Squidex.Infrastructure.Redis/Squidex.Infrastructure.Redis.csproj @@ -11,10 +11,10 @@ - + - - + + ..\..\Squidex.ruleset diff --git a/src/Squidex.Infrastructure/AbsoluteUrlAttribute.cs b/src/Squidex.Infrastructure/AbsoluteUrlAttribute.cs new file mode 100644 index 000000000..e9241d5e9 --- /dev/null +++ b/src/Squidex.Infrastructure/AbsoluteUrlAttribute.cs @@ -0,0 +1,30 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.ComponentModel.DataAnnotations; + +namespace Squidex.Infrastructure +{ + public sealed class AbsoluteUrlAttribute : ValidationAttribute + { + public AbsoluteUrlAttribute() + : base(() => "The {0} field must be an absolute URL.") + { + } + + public override bool IsValid(object value) + { + if (value is Uri uri && !uri.IsAbsoluteUri) + { + return false; + } + + return true; + } + } +} diff --git a/src/Squidex.Infrastructure/Assets/AssetAlreadyExistsException.cs b/src/Squidex.Infrastructure/Assets/AssetAlreadyExistsException.cs new file mode 100644 index 000000000..954f26c4c --- /dev/null +++ b/src/Squidex.Infrastructure/Assets/AssetAlreadyExistsException.cs @@ -0,0 +1,38 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Runtime.Serialization; + +namespace Squidex.Infrastructure.Assets +{ + [Serializable] + public class AssetAlreadyExistsException : Exception + { + public AssetAlreadyExistsException(string fileName) + : base(FormatMessage(fileName)) + { + } + + public AssetAlreadyExistsException(string fileName, Exception inner) + : base(FormatMessage(fileName), inner) + { + } + + protected AssetAlreadyExistsException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } + + private static string FormatMessage(string fileName) + { + Guard.NotNullOrEmpty(fileName, nameof(fileName)); + + return $"An asset with name '{fileName}' already not exists."; + } + } +} diff --git a/src/Squidex.Infrastructure/Assets/AssetFile.cs b/src/Squidex.Infrastructure/Assets/AssetFile.cs index 3b59ba505..bbaa0917d 100644 --- a/src/Squidex.Infrastructure/Assets/AssetFile.cs +++ b/src/Squidex.Infrastructure/Assets/AssetFile.cs @@ -7,6 +7,7 @@ using System; using System.IO; +using Newtonsoft.Json; namespace Squidex.Infrastructure.Assets { @@ -20,11 +21,11 @@ namespace Squidex.Infrastructure.Assets public long FileSize { get; } + [JsonConstructor] public AssetFile(string fileName, string mimeType, long fileSize, Func openAction) { Guard.NotNullOrEmpty(fileName, nameof(fileName)); Guard.NotNullOrEmpty(mimeType, nameof(mimeType)); - Guard.NotNull(openAction, nameof(openAction)); Guard.GreaterEquals(fileSize, 0, nameof(fileSize)); FileName = fileName; diff --git a/src/Squidex.Infrastructure/Assets/AssetNotFoundException.cs b/src/Squidex.Infrastructure/Assets/AssetNotFoundException.cs index 104371efb..1691a8bbf 100644 --- a/src/Squidex.Infrastructure/Assets/AssetNotFoundException.cs +++ b/src/Squidex.Infrastructure/Assets/AssetNotFoundException.cs @@ -13,23 +13,26 @@ namespace Squidex.Infrastructure.Assets [Serializable] public class AssetNotFoundException : Exception { - public AssetNotFoundException() + public AssetNotFoundException(string fileName) + : base(FormatMessage(fileName)) { } - public AssetNotFoundException(string message) - : base(message) + public AssetNotFoundException(string fileName, Exception inner) + : base(FormatMessage(fileName), inner) { } - public AssetNotFoundException(string message, Exception inner) - : base(message, inner) + protected AssetNotFoundException(SerializationInfo info, StreamingContext context) + : base(info, context) { } - protected AssetNotFoundException(SerializationInfo info, StreamingContext context) - : base(info, context) + private static string FormatMessage(string fileName) { + Guard.NotNullOrEmpty(fileName, nameof(fileName)); + + return $"An asset with name '{fileName}' does not exist."; } } } diff --git a/src/Squidex.Infrastructure/Assets/FolderAssetStore.cs b/src/Squidex.Infrastructure/Assets/FolderAssetStore.cs index 396bffbc7..e86c5dd84 100644 --- a/src/Squidex.Infrastructure/Assets/FolderAssetStore.cs +++ b/src/Squidex.Infrastructure/Assets/FolderAssetStore.cs @@ -7,6 +7,7 @@ using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Tasks; @@ -15,6 +16,7 @@ namespace Squidex.Infrastructure.Assets { public sealed class FolderAssetStore : IAssetStore, IInitializable { + private const int BufferSize = 81920; private readonly ISemanticLog log; private readonly DirectoryInfo directory; @@ -57,27 +59,7 @@ namespace Squidex.Infrastructure.Assets return file.FullName; } - public async Task UploadTemporaryAsync(string name, Stream stream) - { - var file = GetFile(name); - - using (var fileStream = file.OpenWrite()) - { - await stream.CopyToAsync(fileStream); - } - } - - public async Task UploadAsync(string id, long version, string suffix, Stream stream) - { - var file = GetFile(id, version, suffix); - - using (var fileStream = file.OpenWrite()) - { - await stream.CopyToAsync(fileStream); - } - } - - public async Task DownloadAsync(string id, long version, string suffix, Stream stream) + public async Task DownloadAsync(string id, long version, string suffix, Stream stream, CancellationToken ct = default(CancellationToken)) { var file = GetFile(id, version, suffix); @@ -85,44 +67,76 @@ namespace Squidex.Infrastructure.Assets { using (var fileStream = file.OpenRead()) { - await fileStream.CopyToAsync(stream); + await fileStream.CopyToAsync(stream, BufferSize, ct); } } catch (FileNotFoundException ex) { - throw new AssetNotFoundException($"Asset {id}, {version} not found.", ex); + throw new AssetNotFoundException($"Id={id}, Version={version}", ex); } } - public Task CopyTemporaryAsync(string name, string id, long version, string suffix) + public Task CopyAsync(string sourceFileName, string id, long version, string suffix, CancellationToken ct = default(CancellationToken)) { + var targetFile = GetFile(id, version, suffix); + try { - var file = GetFile(name); + var file = GetFile(sourceFileName); - file.CopyTo(GetPath(id, version, suffix)); + file.CopyTo(targetFile.FullName); return TaskHelper.Done; } + catch (IOException) when (targetFile.Exists) + { + throw new AssetAlreadyExistsException(targetFile.Name); + } catch (FileNotFoundException ex) { - throw new AssetNotFoundException($"Asset {name} not found.", ex); + throw new AssetNotFoundException(sourceFileName, ex); } } - public Task DeleteTemporaryAsync(string name) + public Task UploadAsync(string id, long version, string suffix, Stream stream, CancellationToken ct = default(CancellationToken)) { - try - { - var file = GetFile(name); + return UploadCoreAsync(GetFile(id, version, suffix), stream, ct); + } - file.Delete(); + public Task UploadAsync(string fileName, Stream stream, CancellationToken ct = default(CancellationToken)) + { + return UploadCoreAsync(GetFile(fileName), stream, ct); + } - return TaskHelper.Done; + public Task DeleteAsync(string id, long version, string suffix) + { + return DeleteFileCoreAsync(GetFile(id, version, suffix)); + } + + public Task DeleteAsync(string fileName) + { + return DeleteFileCoreAsync(GetFile(fileName)); + } + + private static Task DeleteFileCoreAsync(FileInfo file) + { + file.Delete(); + + return TaskHelper.Done; + } + + private static async Task UploadCoreAsync(FileInfo file, Stream stream, CancellationToken ct) + { + try + { + using (var fileStream = file.Open(FileMode.CreateNew, FileAccess.Write)) + { + await stream.CopyToAsync(fileStream, BufferSize, ct); + } } - catch (FileNotFoundException ex) + catch (IOException) when (file.Exists) { - throw new AssetNotFoundException($"Asset {name} not found.", ex); + throw new AssetAlreadyExistsException(file.Name); } } @@ -133,11 +147,11 @@ namespace Squidex.Infrastructure.Assets return GetFile(GetPath(id, version, suffix)); } - private FileInfo GetFile(string name) + private FileInfo GetFile(string fileName) { - Guard.NotNullOrEmpty(name, nameof(name)); + Guard.NotNullOrEmpty(fileName, nameof(fileName)); - return new FileInfo(GetPath(name)); + return new FileInfo(GetPath(fileName)); } private string GetPath(string name) diff --git a/src/Squidex.Infrastructure/Assets/IAssetStore.cs b/src/Squidex.Infrastructure/Assets/IAssetStore.cs index 4c2ab9358..8f954d731 100644 --- a/src/Squidex.Infrastructure/Assets/IAssetStore.cs +++ b/src/Squidex.Infrastructure/Assets/IAssetStore.cs @@ -6,6 +6,7 @@ // ========================================================================== using System.IO; +using System.Threading; using System.Threading.Tasks; namespace Squidex.Infrastructure.Assets @@ -14,14 +15,16 @@ namespace Squidex.Infrastructure.Assets { string GenerateSourceUrl(string id, long version, string suffix); - Task CopyTemporaryAsync(string name, string id, long version, string suffix); + Task CopyAsync(string sourceFileName, string id, long version, string suffix, CancellationToken ct = default(CancellationToken)); - Task DownloadAsync(string id, long version, string suffix, Stream stream); + Task DownloadAsync(string id, long version, string suffix, Stream stream, CancellationToken ct = default(CancellationToken)); - Task UploadTemporaryAsync(string name, Stream stream); + Task UploadAsync(string fileName, Stream stream, CancellationToken ct = default(CancellationToken)); - Task UploadAsync(string id, long version, string suffix, Stream stream); + Task UploadAsync(string id, long version, string suffix, Stream stream, CancellationToken ct = default(CancellationToken)); - Task DeleteTemporaryAsync(string name); + Task DeleteAsync(string fileName); + + Task DeleteAsync(string id, long version, string suffix); } } \ No newline at end of file diff --git a/src/Squidex.Infrastructure/Assets/ImageSharp/ImageSharpAssetThumbnailGenerator.cs b/src/Squidex.Infrastructure/Assets/ImageSharp/ImageSharpAssetThumbnailGenerator.cs index 1bfa03584..cb28e94e7 100644 --- a/src/Squidex.Infrastructure/Assets/ImageSharp/ImageSharpAssetThumbnailGenerator.cs +++ b/src/Squidex.Infrastructure/Assets/ImageSharp/ImageSharpAssetThumbnailGenerator.cs @@ -10,6 +10,7 @@ using System.IO; using System.Threading.Tasks; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Processing; +using SixLabors.ImageSharp.Processing.Transforms; using SixLabors.Primitives; namespace Squidex.Infrastructure.Assets.ImageSharp @@ -18,8 +19,8 @@ namespace Squidex.Infrastructure.Assets.ImageSharp { public ImageSharpAssetThumbnailGenerator() { - Configuration.Default.AddImageFormat(ImageFormats.Jpeg); - Configuration.Default.AddImageFormat(ImageFormats.Png); + Configuration.Default.ImageFormatsManager.AddImageFormat(ImageFormats.Jpeg); + Configuration.Default.ImageFormatsManager.AddImageFormat(ImageFormats.Png); } public Task CreateThumbnailAsync(Stream source, Stream destination, int? width, int? height, string mode) @@ -38,8 +39,8 @@ namespace Squidex.Infrastructure.Assets.ImageSharp resizeMode = ResizeMode.Max; } - var w = width ?? int.MaxValue; - var h = height ?? int.MaxValue; + var w = width ?? 0; + var h = height ?? 0; using (var sourceImage = Image.Load(source, out var format)) { diff --git a/src/Squidex.Infrastructure/Caching/AsyncLocalCache.cs b/src/Squidex.Infrastructure/Caching/AsyncLocalCache.cs new file mode 100644 index 000000000..e6222aa37 --- /dev/null +++ b/src/Squidex.Infrastructure/Caching/AsyncLocalCache.cs @@ -0,0 +1,77 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Concurrent; +using System.Threading; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Infrastructure.Caching +{ + public sealed class AsyncLocalCache : ILocalCache + { + private static readonly AsyncLocal> LocalCache = new AsyncLocal>(); + private static readonly AsyncLocalCleaner> Cleaner; + + static AsyncLocalCache() + { + Cleaner = new AsyncLocalCleaner>(LocalCache); + } + + public IDisposable StartContext() + { + LocalCache.Value = new ConcurrentDictionary(); + + return Cleaner; + } + + public void Add(object key, object value) + { + var cacheKey = GetCacheKey(key); + + var cache = LocalCache.Value; + + if (cache != null) + { + cache[cacheKey] = value; + } + } + + public void Remove(object key) + { + var cacheKey = GetCacheKey(key); + + var cache = LocalCache.Value; + + if (cache != null) + { + cache.TryRemove(cacheKey, out _); + } + } + + public bool TryGetValue(object key, out object value) + { + var cacheKey = GetCacheKey(key); + + var cache = LocalCache.Value; + + if (cache != null) + { + return cache.TryGetValue(cacheKey, out value); + } + + value = null; + + return false; + } + + private static string GetCacheKey(object key) + { + return $"CACHE_{key}"; + } + } +} diff --git a/src/Squidex.Infrastructure/Caching/ILocalCache.cs b/src/Squidex.Infrastructure/Caching/ILocalCache.cs new file mode 100644 index 000000000..5eec26296 --- /dev/null +++ b/src/Squidex.Infrastructure/Caching/ILocalCache.cs @@ -0,0 +1,22 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; + +namespace Squidex.Infrastructure.Caching +{ + public interface ILocalCache + { + IDisposable StartContext(); + + void Add(object key, object value); + + void Remove(object key); + + bool TryGetValue(object key, out object value); + } +} diff --git a/src/Squidex.Infrastructure/Caching/RequestCacheExtensions.cs b/src/Squidex.Infrastructure/Caching/RequestCacheExtensions.cs new file mode 100644 index 000000000..2a3d6ed8c --- /dev/null +++ b/src/Squidex.Infrastructure/Caching/RequestCacheExtensions.cs @@ -0,0 +1,43 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; + +namespace Squidex.Infrastructure.Caching +{ + public static class RequestCacheExtensions + { + public static async Task GetOrCreateAsync(this ILocalCache cache, object key, Func> task) + { + if (cache.TryGetValue(key, out var value) && value is T typedValue) + { + return typedValue; + } + + typedValue = await task(); + + cache.Add(key, typedValue); + + return typedValue; + } + + public static T GetOrCreate(this ILocalCache cache, object key, Func task) + { + if (cache.TryGetValue(key, out var value) && value is T typedValue) + { + return typedValue; + } + + typedValue = task(); + + cache.Add(key, typedValue); + + return typedValue; + } + } +} diff --git a/src/Squidex.Infrastructure/CachingProviderBase.cs b/src/Squidex.Infrastructure/CachingProviderBase.cs new file mode 100644 index 000000000..d52e3be7a --- /dev/null +++ b/src/Squidex.Infrastructure/CachingProviderBase.cs @@ -0,0 +1,28 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.Extensions.Caching.Memory; + +namespace Squidex.Infrastructure +{ + public abstract class CachingProviderBase + { + private readonly IMemoryCache cache; + + protected IMemoryCache Cache + { + get { return cache; } + } + + protected CachingProviderBase(IMemoryCache cache) + { + Guard.NotNull(cache, nameof(cache)); + + this.cache = cache; + } + } +} diff --git a/src/Squidex.Infrastructure/CollectionExtensions.cs b/src/Squidex.Infrastructure/CollectionExtensions.cs index febc9654d..61513594d 100644 --- a/src/Squidex.Infrastructure/CollectionExtensions.cs +++ b/src/Squidex.Infrastructure/CollectionExtensions.cs @@ -14,6 +14,13 @@ namespace Squidex.Infrastructure { public static class CollectionExtensions { + public static IEnumerable Shuffle(this IEnumerable enumerable) + { + var random = new Random(); + + return enumerable.OrderBy(x => random.Next()).ToList(); + } + public static ImmutableDictionary SetItem(this ImmutableDictionary dictionary, TKey key, Func updater) { if (dictionary.TryGetValue(key, out var value)) @@ -160,6 +167,18 @@ namespace Squidex.Infrastructure return result; } + public static TValue GetOrAdd(this IDictionary dictionary, TKey key, TValue fallback) + { + if (!dictionary.TryGetValue(key, out var result)) + { + result = fallback; + + dictionary.Add(key, result); + } + + return result; + } + public static TValue GetOrAdd(this IDictionary dictionary, TKey key, Func creator) { if (!dictionary.TryGetValue(key, out var result)) @@ -172,6 +191,18 @@ namespace Squidex.Infrastructure return result; } + public static TValue GetOrAdd(this IDictionary dictionary, TKey key, TContext context, Func creator) + { + if (!dictionary.TryGetValue(key, out var result)) + { + result = creator(key, context); + + dictionary.Add(key, result); + } + + return result; + } + public static void Foreach(this IEnumerable collection, Action action) { foreach (var item in collection) diff --git a/src/Squidex.Infrastructure/Commands/CommandContext.cs b/src/Squidex.Infrastructure/Commands/CommandContext.cs index a83ba04fb..49fee10e8 100644 --- a/src/Squidex.Infrastructure/Commands/CommandContext.cs +++ b/src/Squidex.Infrastructure/Commands/CommandContext.cs @@ -45,9 +45,11 @@ namespace Squidex.Infrastructure.Commands this.commandBus = commandBus; } - public void Complete(object resultValue = null) + public CommandContext Complete(object resultValue = null) { result = Tuple.Create(resultValue); + + return this; } public T Result() diff --git a/src/Squidex.Infrastructure/Commands/DomainObjectGrain.cs b/src/Squidex.Infrastructure/Commands/DomainObjectGrain.cs index 6d6768581..04b05efd3 100644 --- a/src/Squidex.Infrastructure/Commands/DomainObjectGrain.cs +++ b/src/Squidex.Infrastructure/Commands/DomainObjectGrain.cs @@ -6,199 +6,61 @@ // ========================================================================== using System; -using System.Collections.Generic; using System.Threading.Tasks; using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Log; using Squidex.Infrastructure.States; -using Squidex.Infrastructure.Tasks; namespace Squidex.Infrastructure.Commands { - public abstract class DomainObjectGrain : IDomainObjectGrain where T : IDomainState, new() + public abstract class DomainObjectGrain : DomainObjectGrainBase where T : IDomainState, new() { - private readonly List> uncomittedEvents = new List>(); private readonly IStore store; - private Guid id; private T snapshot = new T { Version = EtagVersion.Empty }; private IPersistence persistence; - public Guid Id - { - get { return id; } - } - - public long Version - { - get { return snapshot.Version; } - } - - public long NewVersion - { - get { return snapshot.Version + uncomittedEvents.Count; } - } - - public T Snapshot + public override T Snapshot { get { return snapshot; } } - protected DomainObjectGrain(IStore store) + protected DomainObjectGrain(IStore store, ISemanticLog log) + : base(log) { Guard.NotNull(store, nameof(store)); this.store = store; } - public Task ActivateAsync(Guid key) + protected sealed override void ApplyEvent(Envelope @event) { - id = key; - - persistence = store.WithSnapshotsAndEventSourcing(GetType(), key, ApplySnapshot, ApplyEvent); + var newVersion = Version + 1; - return persistence.ReadAsync(); + snapshot = OnEvent(@event); + snapshot.Version = newVersion; } - public void RaiseEvent(IEvent @event) + protected sealed override void RestorePreviousSnapshot(T previousSnapshot, long previousVersion) { - RaiseEvent(Envelope.Create(@event)); + snapshot = previousSnapshot; } - public virtual void RaiseEvent(Envelope @event) + protected sealed override Task ReadAsync(Type type, Guid id) { - Guard.NotNull(@event, nameof(@event)); - - @event.SetAggregateId(Id); - - ApplyEvent(@event); - - uncomittedEvents.Add(@event); - } - - public IReadOnlyList> GetUncomittedEvents() - { - return uncomittedEvents; - } - - public void ClearUncommittedEvents() - { - uncomittedEvents.Clear(); - } - - public virtual void ApplySnapshot(T newSnapshot) - { - snapshot = newSnapshot; - } - - public virtual void ApplyEvent(Envelope @event) - { - } - - public Task WriteSnapshotAsync() - { - snapshot.Version = persistence.Version; - - return persistence.WriteSnapshotAsync(snapshot); - } - - protected Task CreateReturnAsync(TCommand command, Func> handler) where TCommand : class, IAggregateCommand - { - return InvokeAsync(command, handler, false); - } - - protected Task CreateReturnAsync(TCommand command, Func handler) where TCommand : class, IAggregateCommand - { - return InvokeAsync(command, handler?.ToAsync(), false); - } + persistence = store.WithSnapshotsAndEventSourcing(GetType(), id, x => snapshot = x, ApplyEvent); - protected Task CreateAsync(TCommand command, Func handler) where TCommand : class, IAggregateCommand - { - return InvokeAsync(command, handler.ToDefault(), false); - } - - protected Task CreateAsync(TCommand command, Action handler) where TCommand : class, IAggregateCommand - { - return InvokeAsync(command, handler?.ToDefault()?.ToAsync(), false); - } - - protected Task UpdateReturnAsync(TCommand command, Func> handler) where TCommand : class, IAggregateCommand - { - return InvokeAsync(command, handler, true); - } - - protected Task UpdateReturnAsync(TCommand command, Func handler) where TCommand : class, IAggregateCommand - { - return InvokeAsync(command, handler?.ToAsync(), true); - } - - protected Task UpdateAsync(TCommand command, Func handler) where TCommand : class, IAggregateCommand - { - return InvokeAsync(command, handler?.ToDefault(), true); - } - - protected Task UpdateAsync(TCommand command, Action handler) where TCommand : class, IAggregateCommand - { - return InvokeAsync(command, handler?.ToDefault()?.ToAsync(), true); + return persistence.ReadAsync(); } - private async Task InvokeAsync(TCommand command, Func> handler, bool isUpdate) where TCommand : class, IAggregateCommand + protected sealed override async Task WriteAsync(Envelope[] events, long previousVersion) { - Guard.NotNull(command, nameof(command)); - - if (command.ExpectedVersion != EtagVersion.Any && command.ExpectedVersion != Version) - { - throw new DomainObjectVersionException(Id.ToString(), GetType(), Version, command.ExpectedVersion); - } - - if (isUpdate && Version < 0) - { - throw new DomainObjectNotFoundException(Id.ToString(), GetType()); - } - else if (!isUpdate && Version >= 0) - { - throw new DomainException("Object has already been created."); - } - - var previousSnapshot = snapshot; - try - { - var result = await handler(command); - - var events = uncomittedEvents.ToArray(); - - if (events.Length > 0) - { - snapshot.Version = NewVersion; - - await persistence.WriteEventsAsync(events); - await persistence.WriteSnapshotAsync(snapshot); - } - - if (result == null) - { - if (isUpdate) - { - result = new EntitySavedResult(Version); - } - else - { - result = EntityCreatedResult.Create(Id, Version); - } - } - - return result; - } - catch - { - snapshot = previousSnapshot; - - throw; - } - finally + if (events.Length > 0) { - ClearUncommittedEvents(); + await persistence.WriteEventsAsync(events); + await persistence.WriteSnapshotAsync(Snapshot); } } - public abstract Task ExecuteAsync(IAggregateCommand command); + protected abstract T OnEvent(Envelope @event); } -} +} \ No newline at end of file diff --git a/src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs b/src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs new file mode 100644 index 000000000..a6b542645 --- /dev/null +++ b/src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs @@ -0,0 +1,202 @@ +// ========================================================================== +// 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.Threading.Tasks; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Orleans; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Infrastructure.Commands +{ + public abstract class DomainObjectGrainBase : GrainOfGuid, IDomainObjectGrain where T : IDomainState, new() + { + private readonly List> uncomittedEvents = new List>(); + private readonly ISemanticLog log; + private Guid id; + + public Guid Id + { + get { return id; } + } + + public long Version + { + get { return Snapshot.Version; } + } + + public abstract T Snapshot { get; } + + protected DomainObjectGrainBase(ISemanticLog log) + { + Guard.NotNull(log, nameof(log)); + + this.log = log; + } + + public sealed override async Task OnActivateAsync(Guid key) + { + using (log.MeasureInformation(w => w + .WriteProperty("action", "ActivateDomainObject") + .WriteProperty("domainObjectType", GetType().Name) + .WriteProperty("domainObjectKey", key.ToString()))) + { + id = key; + + await ReadAsync(GetType(), id); + } + } + + public void RaiseEvent(IEvent @event) + { + RaiseEvent(Envelope.Create(@event)); + } + + public virtual void RaiseEvent(Envelope @event) + { + Guard.NotNull(@event, nameof(@event)); + + @event.SetAggregateId(id); + + ApplyEvent(@event); + + uncomittedEvents.Add(@event); + } + + public IReadOnlyList> GetUncomittedEvents() + { + return uncomittedEvents; + } + + public void ClearUncommittedEvents() + { + uncomittedEvents.Clear(); + } + + protected Task CreateReturnAsync(TCommand command, Func> handler) where TCommand : class, IAggregateCommand + { + return InvokeAsync(command, handler, false); + } + + protected Task CreateReturnAsync(TCommand command, Func handler) where TCommand : class, IAggregateCommand + { + return InvokeAsync(command, handler?.ToAsync(), false); + } + + protected Task CreateAsync(TCommand command, Func handler) where TCommand : class, IAggregateCommand + { + return InvokeAsync(command, handler.ToDefault(), false); + } + + protected Task CreateAsync(TCommand command, Action handler) where TCommand : class, IAggregateCommand + { + return InvokeAsync(command, handler?.ToDefault()?.ToAsync(), false); + } + + protected Task UpdateReturnAsync(TCommand command, Func> handler) where TCommand : class, IAggregateCommand + { + return InvokeAsync(command, handler, true); + } + + protected Task UpdateAsync(TCommand command, Func handler) where TCommand : class, IAggregateCommand + { + return InvokeAsync(command, handler?.ToAsync(), true); + } + + protected Task UpdateAsync(TCommand command, Func handler) where TCommand : class, IAggregateCommand + { + return InvokeAsync(command, handler?.ToDefault(), true); + } + + protected Task UpdateAsync(TCommand command, Action handler) where TCommand : class, IAggregateCommand + { + return InvokeAsync(command, handler?.ToDefault()?.ToAsync(), true); + } + + private async Task InvokeAsync(TCommand command, Func> handler, bool isUpdate) where TCommand : class, IAggregateCommand + { + Guard.NotNull(command, nameof(command)); + + if (command.ExpectedVersion != EtagVersion.Any && command.ExpectedVersion != Version) + { + throw new DomainObjectVersionException(id.ToString(), GetType(), Version, command.ExpectedVersion); + } + + if (isUpdate && Version < 0) + { + try + { + DeactivateOnIdle(); + } + catch (InvalidOperationException) + { + } + + throw new DomainObjectNotFoundException(id.ToString(), GetType()); + } + + if (!isUpdate && Version >= 0) + { + throw new DomainException("Object has already been created."); + } + + var previousSnapshot = Snapshot; + var previousVersion = Version; + try + { + var result = await handler(command); + + var events = uncomittedEvents.ToArray(); + + await WriteAsync(events, previousVersion); + + if (result == null) + { + if (isUpdate) + { + result = new EntitySavedResult(Version); + } + else + { + result = EntityCreatedResult.Create(id, Version); + } + } + + return result; + } + catch + { + RestorePreviousSnapshot(previousSnapshot, previousVersion); + + throw; + } + finally + { + uncomittedEvents.Clear(); + } + } + + protected abstract void RestorePreviousSnapshot(T previousSnapshot, long previousVersion); + + protected abstract void ApplyEvent(Envelope @event); + + protected abstract Task ReadAsync(Type type, Guid id); + + protected abstract Task WriteAsync(Envelope[] events, long previousVersion); + + public async Task> ExecuteAsync(J command) + { + var result = await ExecuteAsync(command.Value); + + return result.AsJ(); + } + + protected abstract Task ExecuteAsync(IAggregateCommand command); + } +} \ No newline at end of file diff --git a/src/Squidex.Infrastructure/Commands/DomainObjectGrainFormatter.cs b/src/Squidex.Infrastructure/Commands/DomainObjectGrainFormatter.cs new file mode 100644 index 000000000..68434279d --- /dev/null +++ b/src/Squidex.Infrastructure/Commands/DomainObjectGrainFormatter.cs @@ -0,0 +1,36 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Orleans; + +namespace Squidex.Infrastructure.Commands +{ + public static class DomainObjectGrainFormatter + { + public static string Format(IGrainCallContext context) + { + if (context.InterfaceMethod == null) + { + return "Unknown"; + } + + if (string.Equals(context.InterfaceMethod.Name, nameof(IDomainObjectGrain.ExecuteAsync), StringComparison.CurrentCultureIgnoreCase) && + context.Arguments?.Length == 1 && + context.Arguments[0] != null) + { + var argumentFullName = context.Arguments[0].ToString(); + var argumentParts = argumentFullName.Split('.'); + var argumentName = argumentParts[argumentParts.Length - 1]; + + return $"{nameof(IDomainObjectGrain.ExecuteAsync)}({argumentName})"; + } + + return context.InterfaceMethod.Name; + } + } +} diff --git a/src/Squidex.Infrastructure/Commands/GrainCommandMiddleware.cs b/src/Squidex.Infrastructure/Commands/GrainCommandMiddleware.cs index a6ef41363..2811548ab 100644 --- a/src/Squidex.Infrastructure/Commands/GrainCommandMiddleware.cs +++ b/src/Squidex.Infrastructure/Commands/GrainCommandMiddleware.cs @@ -7,22 +7,22 @@ using System; using System.Threading.Tasks; -using Squidex.Infrastructure.States; +using Orleans; namespace Squidex.Infrastructure.Commands { public class GrainCommandMiddleware : ICommandMiddleware where TCommand : IAggregateCommand where TGrain : IDomainObjectGrain { - private readonly IStateFactory stateFactory; + private readonly IGrainFactory grainFactory; - public GrainCommandMiddleware(IStateFactory stateFactory) + public GrainCommandMiddleware(IGrainFactory grainFactory) { - Guard.NotNull(stateFactory, nameof(stateFactory)); + Guard.NotNull(grainFactory, nameof(grainFactory)); - this.stateFactory = stateFactory; + this.grainFactory = grainFactory; } - public async virtual Task HandleAsync(CommandContext context, Func next) + public virtual async Task HandleAsync(CommandContext context, Func next) { if (context.Command is TCommand typedCommand) { @@ -36,11 +36,11 @@ namespace Squidex.Infrastructure.Commands protected async Task ExecuteCommandAsync(TCommand typedCommand) { - var grain = await stateFactory.CreateAsync(typedCommand.AggregateId); + var grain = grainFactory.GetGrain(typedCommand.AggregateId); var result = await grain.ExecuteAsync(typedCommand); - return result; + return result.Value; } } } diff --git a/src/Squidex.Infrastructure/Commands/IDomainObjectGrain.cs b/src/Squidex.Infrastructure/Commands/IDomainObjectGrain.cs index 37c80cdb6..f52ce2122 100644 --- a/src/Squidex.Infrastructure/Commands/IDomainObjectGrain.cs +++ b/src/Squidex.Infrastructure/Commands/IDomainObjectGrain.cs @@ -5,18 +5,14 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Threading.Tasks; -using Squidex.Infrastructure.States; +using Orleans; +using Squidex.Infrastructure.Orleans; namespace Squidex.Infrastructure.Commands { - public interface IDomainObjectGrain : IStatefulObject + public interface IDomainObjectGrain : IGrainWithGuidKey { - Task ExecuteAsync(IAggregateCommand command); - - Task WriteSnapshotAsync(); - - long Version { get; } + Task> ExecuteAsync(J command); } -} \ No newline at end of file +} diff --git a/src/Squidex.Infrastructure/Commands/LogSnapshotDomainObjectGrain.cs b/src/Squidex.Infrastructure/Commands/LogSnapshotDomainObjectGrain.cs new file mode 100644 index 000000000..417b4f1e3 --- /dev/null +++ b/src/Squidex.Infrastructure/Commands/LogSnapshotDomainObjectGrain.cs @@ -0,0 +1,93 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.States; + +namespace Squidex.Infrastructure.Commands +{ + public abstract class LogSnapshotDomainObjectGrain : DomainObjectGrainBase where T : IDomainState, new() + { + private readonly IStore store; + private readonly List snapshots = new List { new T { Version = EtagVersion.Empty } }; + private IPersistence persistence; + + public override T Snapshot + { + get { return snapshots.Last(); } + } + + protected LogSnapshotDomainObjectGrain(IStore store, ISemanticLog log) + : base(log) + { + Guard.NotNull(log, nameof(log)); + + this.store = store; + } + + public T GetSnapshot(long version) + { + if (version == EtagVersion.Any) + { + return Snapshot; + } + + if (version == EtagVersion.Empty) + { + return snapshots[0]; + } + + if (version >= 0 && version < snapshots.Count - 1) + { + return snapshots[(int)version + 1]; + } + + return default(T); + } + + protected sealed override void ApplyEvent(Envelope @event) + { + var snapshot = OnEvent(@event); + + snapshot.Version = Version + 1; + snapshots.Add(snapshot); + } + + protected sealed override Task ReadAsync(Type type, Guid id) + { + persistence = store.WithEventSourcing(type, id, ApplyEvent); + + return persistence.ReadAsync(); + } + + protected sealed override async Task WriteAsync(Envelope[] events, long previousVersion) + { + if (events.Length > 0) + { + var persistedSnapshots = store.GetSnapshotStore(); + + await persistence.WriteEventsAsync(events); + await persistedSnapshots.WriteAsync(Id, Snapshot, previousVersion, previousVersion + events.Length); + } + } + + protected sealed override void RestorePreviousSnapshot(T previousSnapshot, long previousVersion) + { + while (snapshots.Count > previousVersion + 2) + { + snapshots.RemoveAt(snapshots.Count - 1); + } + } + + protected abstract T OnEvent(Envelope @event); + } +} \ No newline at end of file diff --git a/src/Squidex.Infrastructure/Commands/ReadonlyCommandMiddleware.cs b/src/Squidex.Infrastructure/Commands/ReadonlyCommandMiddleware.cs new file mode 100644 index 000000000..dcd1ca88b --- /dev/null +++ b/src/Squidex.Infrastructure/Commands/ReadonlyCommandMiddleware.cs @@ -0,0 +1,35 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; + +namespace Squidex.Infrastructure.Commands +{ + public sealed class ReadonlyCommandMiddleware : ICommandMiddleware + { + private readonly IOptions options; + + public ReadonlyCommandMiddleware(IOptions options) + { + Guard.NotNull(options, nameof(options)); + + this.options = options; + } + + public Task HandleAsync(CommandContext context, Func next) + { + if (options.Value.IsReadonly) + { + throw new DomainException("Application is in readonly mode at the moment."); + } + + return next(); + } + } +} diff --git a/src/Squidex.Infrastructure/Commands/ReadonlyOptions.cs b/src/Squidex.Infrastructure/Commands/ReadonlyOptions.cs new file mode 100644 index 000000000..b5682c63d --- /dev/null +++ b/src/Squidex.Infrastructure/Commands/ReadonlyOptions.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Infrastructure.Commands +{ + public sealed class ReadonlyOptions + { + public bool IsReadonly { get; set; } + } +} diff --git a/src/Squidex.Infrastructure/Commands/SyncedGrainCommandMiddleware.cs b/src/Squidex.Infrastructure/Commands/SyncedGrainCommandMiddleware.cs deleted file mode 100644 index d3d85159f..000000000 --- a/src/Squidex.Infrastructure/Commands/SyncedGrainCommandMiddleware.cs +++ /dev/null @@ -1,63 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Threading.Tasks; -using Squidex.Infrastructure.States; -using Squidex.Infrastructure.Tasks; - -namespace Squidex.Infrastructure.Commands -{ - public class SyncedGrainCommandMiddleware : ICommandMiddleware where TCommand : IAggregateCommand where TGrain : IDomainObjectGrain - { - private readonly AsyncLockPool lockPool = new AsyncLockPool(10000); - private readonly IStateFactory stateFactory; - - public SyncedGrainCommandMiddleware(IStateFactory stateFactory) - { - Guard.NotNull(stateFactory, nameof(stateFactory)); - - this.stateFactory = stateFactory; - } - - public async virtual Task HandleAsync(CommandContext context, Func next) - { - if (context.Command is TCommand typedCommand) - { - var result = await ExecuteCommandAsync(typedCommand); - - context.Complete(result); - } - - await next(); - } - - protected async Task ExecuteCommandAsync(TCommand typedCommand) - { - var id = typedCommand.AggregateId; - - using (await lockPool.LockAsync(typedCommand.AggregateId)) - { - try - { - var grain = await stateFactory.GetSingleAsync(id); - - var result = await grain.ExecuteAsync(typedCommand); - - stateFactory.Synchronize(id); - - return result; - } - catch - { - stateFactory.Remove(id); - throw; - } - } - } - } -} diff --git a/src/Squidex.Infrastructure/DelegateDisposable.cs b/src/Squidex.Infrastructure/DelegateDisposable.cs new file mode 100644 index 000000000..bbdbb0262 --- /dev/null +++ b/src/Squidex.Infrastructure/DelegateDisposable.cs @@ -0,0 +1,28 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; + +namespace Squidex.Infrastructure +{ + public sealed class DelegateDisposable : IDisposable + { + private readonly Action action; + + public DelegateDisposable(Action action) + { + Guard.NotNull(action, nameof(action)); + + this.action = action; + } + + public void Dispose() + { + action(); + } + } +} diff --git a/src/Squidex.Infrastructure/DomainObjectException.cs b/src/Squidex.Infrastructure/DomainObjectException.cs index 1fb8fb7cd..24d311688 100644 --- a/src/Squidex.Infrastructure/DomainObjectException.cs +++ b/src/Squidex.Infrastructure/DomainObjectException.cs @@ -37,6 +37,17 @@ namespace Squidex.Infrastructure protected DomainObjectException(SerializationInfo info, StreamingContext context) : base(info, context) { + id = info.GetString(nameof(id)); + + typeName = info.GetString(nameof(typeName)); + } + + public override void GetObjectData(SerializationInfo info, StreamingContext context) + { + info.AddValue(nameof(id), id); + info.AddValue(nameof(typeName), typeName); + + base.GetObjectData(info, context); } } } diff --git a/src/Squidex.Infrastructure/DomainObjectVersionException.cs b/src/Squidex.Infrastructure/DomainObjectVersionException.cs index 225ffd9f6..22192c423 100644 --- a/src/Squidex.Infrastructure/DomainObjectVersionException.cs +++ b/src/Squidex.Infrastructure/DomainObjectVersionException.cs @@ -37,6 +37,17 @@ namespace Squidex.Infrastructure protected DomainObjectVersionException(SerializationInfo info, StreamingContext context) : base(info, context) { + currentVersion = info.GetInt64(nameof(currentVersion)); + + expectedVersion = info.GetInt64(nameof(expectedVersion)); + } + + public override void GetObjectData(SerializationInfo info, StreamingContext context) + { + info.AddValue(nameof(currentVersion), currentVersion); + info.AddValue(nameof(expectedVersion), expectedVersion); + + base.GetObjectData(info, context); } private static string FormatMessage(string id, Type type, long currentVersion, long expectedVersion) diff --git a/src/Squidex.Infrastructure/EventSourcing/DefaultEventNotifier.cs b/src/Squidex.Infrastructure/EventSourcing/DefaultEventNotifier.cs deleted file mode 100644 index 1e94354e3..000000000 --- a/src/Squidex.Infrastructure/EventSourcing/DefaultEventNotifier.cs +++ /dev/null @@ -1,40 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; - -namespace Squidex.Infrastructure.EventSourcing -{ - public sealed class DefaultEventNotifier : IEventNotifier - { - private static readonly string ChannelName = typeof(DefaultEventNotifier).Name; - - private readonly IPubSub pubsub; - - public sealed class EventNotification - { - public string StreamName { get; set; } - } - - public DefaultEventNotifier(IPubSub pubsub) - { - Guard.NotNull(pubsub, nameof(pubsub)); - - this.pubsub = pubsub; - } - - public void NotifyEventsStored(string streamName) - { - pubsub.Publish(new EventNotification { StreamName = streamName }, true); - } - - public IDisposable Subscribe(Action handler) - { - return pubsub.Subscribe(x => handler?.Invoke(x.StreamName)); - } - } -} diff --git a/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs b/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs index e05a08c65..433cf5caa 100644 --- a/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs +++ b/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs @@ -8,25 +8,30 @@ using System; using System.Runtime.CompilerServices; using System.Threading.Tasks; +using Orleans; +using Orleans.Concurrency; using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.States; using Squidex.Infrastructure.Tasks; namespace Squidex.Infrastructure.EventSourcing.Grains { - public class EventConsumerGrain : DisposableObjectBase, IStatefulObject, IEventSubscriber + public class EventConsumerGrain : GrainOfString, IEventConsumerGrain { - private readonly IEventDataFormatter eventDataFormatter; + private readonly EventConsumerFactory eventConsumerFactory; private readonly IStore store; + private readonly IEventDataFormatter eventDataFormatter; private readonly IEventStore eventStore; private readonly ISemanticLog log; - private readonly SingleThreadedDispatcher dispatcher = new SingleThreadedDispatcher(1); + private TaskScheduler scheduler; + private IPersistence persistence; private IEventSubscription currentSubscription; private IEventConsumer eventConsumer; - private IPersistence persistence; private EventConsumerState state = new EventConsumerState(); public EventConsumerGrain( + EventConsumerFactory eventConsumerFactory, IStore store, IEventStore eventStore, IEventDataFormatter eventDataFormatter, @@ -36,95 +41,54 @@ namespace Squidex.Infrastructure.EventSourcing.Grains Guard.NotNull(store, nameof(store)); Guard.NotNull(eventStore, nameof(eventStore)); Guard.NotNull(eventDataFormatter, nameof(eventDataFormatter)); + Guard.NotNull(eventConsumerFactory, nameof(eventConsumerFactory)); this.log = log; this.store = store; this.eventStore = eventStore; this.eventDataFormatter = eventDataFormatter; + this.eventConsumerFactory = eventConsumerFactory; } - protected override void DisposeObject(bool disposing) - { - if (disposing) - { - dispatcher.StopAndWaitAsync().Wait(); - } - } - - public Task ActivateAsync(string key) + public override Task OnActivateAsync(string key) { - persistence = store.WithSnapshots(key, s => state = s); - - return persistence.ReadAsync(); - } + scheduler = TaskScheduler.Current; - protected virtual IEventSubscription CreateSubscription(IEventStore eventStore, string streamFilter, string position) - { - return new RetrySubscription(eventStore, this, streamFilter, position); - } + eventConsumer = eventConsumerFactory(key); - public virtual EventConsumerInfo GetState() - { - return state.ToInfo(this.eventConsumer.Name); - } + persistence = store.WithSnapshots(GetType(), eventConsumer.Name, s => state = s); - public virtual void Stop() - { - dispatcher.DispatchAsync(HandleStopAsync).Forget(); - } - - public virtual void Start() - { - dispatcher.DispatchAsync(HandleStartAsync).Forget(); + return persistence.ReadAsync(); } - public virtual void Reset() + public Task> GetStateAsync() { - dispatcher.DispatchAsync(HandleResetAsync).Forget(); + return Task.FromResult(state.ToInfo(eventConsumer.Name).AsImmutable()); } - public virtual void Activate(IEventConsumer eventConsumer) + public Task OnEventAsync(Immutable subscription, Immutable storedEvent) { - Guard.NotNull(eventConsumer, nameof(eventConsumer)); - - dispatcher.DispatchAsync(() => HandleSetupAsync(eventConsumer)).Forget(); - } - - private Task HandleSetupAsync(IEventConsumer consumer) - { - eventConsumer = consumer; - - if (!state.IsStopped) - { - Subscribe(state.Position); - } - - return TaskHelper.Done; - } - - private Task HandleEventAsync(IEventSubscription subscription, StoredEvent storedEvent) - { - if (subscription != currentSubscription) + if (subscription.Value != currentSubscription) { return TaskHelper.Done; } return DoAndUpdateStateAsync(async () => { - var @event = ParseKnownEvent(storedEvent); + var @event = ParseKnownEvent(storedEvent.Value); if (@event != null) { await DispatchConsumerAsync(@event); } - state = state.Handled(storedEvent.EventPosition); + state = state.Handled(storedEvent.Value.EventPosition); }); } - private Task HandleErrorAsync(IEventSubscription subscription, Exception exception) + public Task OnErrorAsync(Immutable subscription, Immutable exception) { - if (subscription != currentSubscription) + if (subscription.Value != currentSubscription) { return TaskHelper.Done; } @@ -133,11 +97,21 @@ namespace Squidex.Infrastructure.EventSourcing.Grains { Unsubscribe(); - state = state.Failed(exception); + state = state.Failed(exception.Value); }); } - private Task HandleStartAsync() + public Task ActivateAsync() + { + if (!state.IsStopped) + { + Subscribe(state.Position); + } + + return TaskHelper.Done; + } + + public Task StartAsync() { if (!state.IsStopped) { @@ -152,7 +126,7 @@ namespace Squidex.Infrastructure.EventSourcing.Grains }); } - private Task HandleStopAsync() + public Task StopAsync() { if (state.IsStopped) { @@ -167,7 +141,7 @@ namespace Squidex.Infrastructure.EventSourcing.Grains }); } - private Task HandleResetAsync() + public Task ResetAsync() { return DoAndUpdateStateAsync(async () => { @@ -181,16 +155,6 @@ namespace Squidex.Infrastructure.EventSourcing.Grains }); } - Task IEventSubscriber.OnEventAsync(IEventSubscription subscription, StoredEvent storedEvent) - { - return dispatcher.DispatchAsync(() => HandleEventAsync(subscription, storedEvent)); - } - - Task IEventSubscriber.OnErrorAsync(IEventSubscription subscription, Exception exception) - { - return dispatcher.DispatchAsync(() => HandleErrorAsync(subscription, exception)); - } - private Task DoAndUpdateStateAsync(Action action, [CallerMemberName] string caller = null) { return DoAndUpdateStateAsync(() => { action(); return TaskHelper.Done; }, caller); @@ -283,7 +247,11 @@ namespace Squidex.Infrastructure.EventSourcing.Grains if (currentSubscription == null) { currentSubscription?.StopAsync().Forget(); - currentSubscription = CreateSubscription(eventStore, eventConsumer.EventsFilter, position); + currentSubscription = CreateSubscription(eventConsumer.EventsFilter, position); + } + else + { + currentSubscription.WakeUp(); } } @@ -305,5 +273,20 @@ namespace Squidex.Infrastructure.EventSourcing.Grains return null; } } + + protected virtual IEventConsumerGrain GetSelf() + { + return this.AsReference(); + } + + protected virtual IEventSubscription CreateSubscription(IEventStore store, IEventSubscriber subscriber, string streamFilter, string position) + { + return new RetrySubscription(store, subscriber, streamFilter, position); + } + + private IEventSubscription CreateSubscription(string streamFilter, string position) + { + return CreateSubscription(eventStore, new WrapperSubscription(GetSelf(), scheduler), streamFilter, position); + } } } \ No newline at end of file diff --git a/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrainManager.cs b/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrainManager.cs deleted file mode 100644 index e833aa5b4..000000000 --- a/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrainManager.cs +++ /dev/null @@ -1,90 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Squidex.Infrastructure.EventSourcing.Grains.Messages; -using Squidex.Infrastructure.States; - -namespace Squidex.Infrastructure.EventSourcing.Grains -{ - public sealed class EventConsumerGrainManager : DisposableObjectBase, IRunnable - { - private readonly IStateFactory factory; - private readonly IPubSub pubSub; - private readonly List consumers; - private readonly List subscriptions = new List(); - - public EventConsumerGrainManager(IEnumerable consumers, IPubSub pubSub, IStateFactory factory) - { - Guard.NotNull(pubSub, nameof(pubSub)); - Guard.NotNull(factory, nameof(factory)); - Guard.NotNull(consumers, nameof(consumers)); - - this.pubSub = pubSub; - this.factory = factory; - this.consumers = consumers.ToList(); - } - - public void Run() - { - var actors = new Dictionary(); - - foreach (var consumer in consumers) - { - var actor = factory.CreateAsync(consumer.Name).Result; - - actors[consumer.Name] = actor; - actor.Activate(consumer); - } - - subscriptions.Add(pubSub.Subscribe(m => - { - if (actors.TryGetValue(m.ConsumerName, out var actor)) - { - actor.Start(); - } - })); - - subscriptions.Add(pubSub.Subscribe(m => - { - if (actors.TryGetValue(m.ConsumerName, out var actor)) - { - actor.Stop(); - } - })); - - subscriptions.Add(pubSub.Subscribe(m => - { - if (actors.TryGetValue(m.ConsumerName, out var actor)) - { - actor.Reset(); - } - })); - - subscriptions.Add(pubSub.ReceiveAsync(request => - { - var states = actors.Values.Select(x => x.GetState()).ToArray(); - - return Task.FromResult(new GetStatesResponse { States = states }); - })); - } - - protected override void DisposeObject(bool disposing) - { - if (disposing) - { - foreach (var subscription in subscriptions) - { - subscription.Dispose(); - } - } - } - } -} diff --git a/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerManagerGrain.cs b/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerManagerGrain.cs new file mode 100644 index 000000000..ca9097142 --- /dev/null +++ b/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerManagerGrain.cs @@ -0,0 +1,120 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Orleans; +using Orleans.Concurrency; +using Orleans.Core; +using Orleans.Runtime; + +namespace Squidex.Infrastructure.EventSourcing.Grains +{ + public class EventConsumerManagerGrain : Grain, IEventConsumerManagerGrain, IRemindable + { + private readonly IEnumerable eventConsumers; + + public EventConsumerManagerGrain(IEnumerable eventConsumers) + : this(eventConsumers, null, null) + { + } + + protected EventConsumerManagerGrain( + IEnumerable eventConsumers, + IGrainIdentity identity, + IGrainRuntime runtime) + : base(identity, runtime) + { + Guard.NotNull(eventConsumers, nameof(eventConsumers)); + + this.eventConsumers = eventConsumers; + } + + public override Task OnActivateAsync() + { + DelayDeactivation(TimeSpan.FromDays(1)); + + RegisterOrUpdateReminder("Default", TimeSpan.Zero, TimeSpan.FromMinutes(10)); + RegisterTimer(x => ActivateAsync(null), null, TimeSpan.Zero, TimeSpan.FromSeconds(10)); + + return Task.FromResult(true); + } + + public Task ActivateAsync(string streamName) + { + var tasks = + eventConsumers + .Where(c => streamName == null || Regex.IsMatch(streamName, c.EventsFilter)) + .Select(c => GrainFactory.GetGrain(c.Name)) + .Select(c => c.ActivateAsync()); + + return Task.WhenAll(tasks); + } + + public async Task>> GetConsumersAsync() + { + var tasks = + eventConsumers + .Select(c => GrainFactory.GetGrain(c.Name)) + .Select(c => c.GetStateAsync()); + + var consumerInfos = await Task.WhenAll(tasks); + + return new Immutable>(consumerInfos.Select(r => r.Value).ToList()); + } + + public Task StartAllAsync() + { + return Task.WhenAll( + eventConsumers + .Select(c => GrainFactory.GetGrain(c.Name)) + .Select(c => c.StartAsync())); + } + + public Task StopAllAsync() + { + return Task.WhenAll( + eventConsumers + .Select(c => GrainFactory.GetGrain(c.Name)) + .Select(c => c.StopAsync())); + } + + public Task ResetAsync(string consumerName) + { + var eventConsumer = GrainFactory.GetGrain(consumerName); + + return eventConsumer.ResetAsync(); + } + + public Task StartAsync(string consumerName) + { + var eventConsumer = GrainFactory.GetGrain(consumerName); + + return eventConsumer.StartAsync(); + } + + public Task StopAsync(string consumerName) + { + var eventConsumer = GrainFactory.GetGrain(consumerName); + + return eventConsumer.StopAsync(); + } + + public Task ActivateAsync() + { + return ActivateAsync(null); + } + + public Task ReceiveReminder(string reminderName, TickStatus status) + { + return ActivateAsync(null); + } + } +} diff --git a/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerGrain.cs b/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerGrain.cs new file mode 100644 index 000000000..58b7bf2fb --- /dev/null +++ b/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerGrain.cs @@ -0,0 +1,29 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Orleans.Concurrency; +using Squidex.Infrastructure.Orleans; + +namespace Squidex.Infrastructure.EventSourcing.Grains +{ + public interface IEventConsumerGrain : IBackgroundGrain + { + Task> GetStateAsync(); + + Task StopAsync(); + + Task StartAsync(); + + Task ResetAsync(); + + Task OnEventAsync(Immutable subscription, Immutable storedEvent); + + Task OnErrorAsync(Immutable subscription, Immutable exception); + } +} diff --git a/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerManagerGrain.cs b/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerManagerGrain.cs new file mode 100644 index 000000000..c0b53d403 --- /dev/null +++ b/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerManagerGrain.cs @@ -0,0 +1,31 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Threading.Tasks; +using Orleans.Concurrency; +using Squidex.Infrastructure.Orleans; + +namespace Squidex.Infrastructure.EventSourcing.Grains +{ + public interface IEventConsumerManagerGrain : IBackgroundGrain + { + Task ActivateAsync(string streamName); + + Task StopAllAsync(); + + Task StopAsync(string consumerName); + + Task StartAllAsync(); + + Task StartAsync(string consumerName); + + Task ResetAsync(string consumerName); + + Task>> GetConsumersAsync(); + } +} diff --git a/src/Squidex.Infrastructure/EventSourcing/Grains/Messages/GetStatesRequest.cs b/src/Squidex.Infrastructure/EventSourcing/Grains/Messages/GetStatesRequest.cs deleted file mode 100644 index d193d7ebb..000000000 --- a/src/Squidex.Infrastructure/EventSourcing/Grains/Messages/GetStatesRequest.cs +++ /dev/null @@ -1,13 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -namespace Squidex.Infrastructure.EventSourcing.Grains.Messages -{ - public sealed class GetStatesRequest - { - } -} diff --git a/src/Squidex.Infrastructure/EventSourcing/Grains/Messages/GetStatesResponse.cs b/src/Squidex.Infrastructure/EventSourcing/Grains/Messages/GetStatesResponse.cs deleted file mode 100644 index 922116d82..000000000 --- a/src/Squidex.Infrastructure/EventSourcing/Grains/Messages/GetStatesResponse.cs +++ /dev/null @@ -1,14 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -namespace Squidex.Infrastructure.EventSourcing.Grains.Messages -{ - public sealed class GetStatesResponse - { - public EventConsumerInfo[] States { get; set; } - } -} diff --git a/src/Squidex.Infrastructure/EventSourcing/Grains/Messages/ResetConsumerMessage.cs b/src/Squidex.Infrastructure/EventSourcing/Grains/Messages/ResetConsumerMessage.cs deleted file mode 100644 index 012cfd2e1..000000000 --- a/src/Squidex.Infrastructure/EventSourcing/Grains/Messages/ResetConsumerMessage.cs +++ /dev/null @@ -1,14 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -namespace Squidex.Infrastructure.EventSourcing.Grains.Messages -{ - public sealed class ResetConsumerMessage - { - public string ConsumerName { get; set; } - } -} diff --git a/src/Squidex.Infrastructure/EventSourcing/Grains/Messages/StartConsumerMessage.cs b/src/Squidex.Infrastructure/EventSourcing/Grains/Messages/StartConsumerMessage.cs deleted file mode 100644 index 8d8378653..000000000 --- a/src/Squidex.Infrastructure/EventSourcing/Grains/Messages/StartConsumerMessage.cs +++ /dev/null @@ -1,14 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -namespace Squidex.Infrastructure.EventSourcing.Grains.Messages -{ - public sealed class StartConsumerMessage - { - public string ConsumerName { get; set; } - } -} diff --git a/src/Squidex.Infrastructure/EventSourcing/Grains/Messages/StopConsumerMessage.cs b/src/Squidex.Infrastructure/EventSourcing/Grains/Messages/StopConsumerMessage.cs deleted file mode 100644 index 5a354a468..000000000 --- a/src/Squidex.Infrastructure/EventSourcing/Grains/Messages/StopConsumerMessage.cs +++ /dev/null @@ -1,14 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -namespace Squidex.Infrastructure.EventSourcing.Grains.Messages -{ - public sealed class StopConsumerMessage - { - public string ConsumerName { get; set; } - } -} diff --git a/src/Squidex.Infrastructure/EventSourcing/Grains/OrleansEventNotifier.cs b/src/Squidex.Infrastructure/EventSourcing/Grains/OrleansEventNotifier.cs new file mode 100644 index 000000000..6e3da7063 --- /dev/null +++ b/src/Squidex.Infrastructure/EventSourcing/Grains/OrleansEventNotifier.cs @@ -0,0 +1,38 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Orleans; +using Squidex.Infrastructure.Orleans; + +namespace Squidex.Infrastructure.EventSourcing.Grains +{ + public sealed class OrleansEventNotifier : IEventNotifier + { + private readonly Lazy eventConsumerManagerGrain; + + public OrleansEventNotifier(IGrainFactory factory) + { + Guard.NotNull(factory, nameof(factory)); + + eventConsumerManagerGrain = new Lazy(() => + { + return factory.GetGrain(SingleGrain.Id); + }); + } + + public void NotifyEventsStored(string streamName) + { + eventConsumerManagerGrain.Value.ActivateAsync(streamName); + } + + public IDisposable Subscribe(Action handler) + { + return null; + } + } +} diff --git a/src/Squidex.Infrastructure/EventSourcing/Grains/WrapperSubscription.cs b/src/Squidex.Infrastructure/EventSourcing/Grains/WrapperSubscription.cs new file mode 100644 index 000000000..6862a1504 --- /dev/null +++ b/src/Squidex.Infrastructure/EventSourcing/Grains/WrapperSubscription.cs @@ -0,0 +1,42 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading; +using System.Threading.Tasks; +using Orleans.Concurrency; + +namespace Squidex.Infrastructure.EventSourcing.Grains +{ + internal sealed class WrapperSubscription : IEventSubscriber + { + private readonly IEventConsumerGrain grain; + private readonly TaskScheduler scheduler; + + public WrapperSubscription(IEventConsumerGrain grain, TaskScheduler scheduler) + { + this.grain = grain; + + this.scheduler = scheduler ?? TaskScheduler.Default; + } + + public Task OnEventAsync(IEventSubscription subscription, StoredEvent storedEvent) + { + return Dispatch(() => grain.OnEventAsync(subscription.AsImmutable(), storedEvent.AsImmutable())); + } + + public Task OnErrorAsync(IEventSubscription subscription, Exception exception) + { + return Dispatch(() => grain.OnErrorAsync(subscription.AsImmutable(), exception.AsImmutable())); + } + + private Task Dispatch(Func task) + { + return Task.Factory.StartNew(task, CancellationToken.None, TaskCreationOptions.None, scheduler).Unwrap(); + } + } +} diff --git a/src/Squidex.Infrastructure/EventSourcing/IEventNotifier.cs b/src/Squidex.Infrastructure/EventSourcing/IEventNotifier.cs index ce28bb491..6e5bbe94f 100644 --- a/src/Squidex.Infrastructure/EventSourcing/IEventNotifier.cs +++ b/src/Squidex.Infrastructure/EventSourcing/IEventNotifier.cs @@ -5,14 +5,10 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; - namespace Squidex.Infrastructure.EventSourcing { public interface IEventNotifier { void NotifyEventsStored(string streamName); - - IDisposable Subscribe(Action handler); } } diff --git a/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs b/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs index c33d86e5a..186b7310e 100644 --- a/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs +++ b/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs @@ -26,6 +26,10 @@ namespace Squidex.Infrastructure.EventSourcing Task AppendAsync(Guid commitId, string streamName, long expectedVersion, ICollection events); + Task DeleteStreamAsync(string streamName); + + Task DeleteManyAsync(string property, object value); + IEventSubscription CreateSubscription(IEventSubscriber subscriber, string streamFilter, string position = null); } } diff --git a/src/Squidex.Infrastructure/EventSourcing/IEventSubscription.cs b/src/Squidex.Infrastructure/EventSourcing/IEventSubscription.cs index a33b1f22b..48ead1da9 100644 --- a/src/Squidex.Infrastructure/EventSourcing/IEventSubscription.cs +++ b/src/Squidex.Infrastructure/EventSourcing/IEventSubscription.cs @@ -11,6 +11,8 @@ namespace Squidex.Infrastructure.EventSourcing { public interface IEventSubscription { + void WakeUp(); + Task StopAsync(); } } \ No newline at end of file diff --git a/src/Squidex.Infrastructure/EventSourcing/PollingSubscription.cs b/src/Squidex.Infrastructure/EventSourcing/PollingSubscription.cs index 7cbb556b9..59b8047cb 100644 --- a/src/Squidex.Infrastructure/EventSourcing/PollingSubscription.cs +++ b/src/Squidex.Infrastructure/EventSourcing/PollingSubscription.cs @@ -6,7 +6,6 @@ // ========================================================================== using System; -using System.Text.RegularExpressions; using System.Threading.Tasks; using Squidex.Infrastructure.Timers; @@ -14,34 +13,17 @@ namespace Squidex.Infrastructure.EventSourcing { public sealed class PollingSubscription : IEventSubscription { - private readonly IEventNotifier eventNotifier; - private readonly IEventStore eventStore; - private readonly IEventSubscriber eventSubscriber; - private readonly IDisposable notification; private readonly CompletionTimer timer; - private readonly Regex streamRegex; - private readonly string streamFilter; - private string position; public PollingSubscription( IEventStore eventStore, - IEventNotifier eventNotifier, IEventSubscriber eventSubscriber, string streamFilter, string position) { Guard.NotNull(eventStore, nameof(eventStore)); - Guard.NotNull(eventNotifier, nameof(eventNotifier)); Guard.NotNull(eventSubscriber, nameof(eventSubscriber)); - this.position = position; - this.eventNotifier = eventNotifier; - this.eventStore = eventStore; - this.eventSubscriber = eventSubscriber; - this.streamFilter = streamFilter; - - streamRegex = new Regex(streamFilter); - timer = new CompletionTimer(5000, async ct => { try @@ -61,20 +43,15 @@ namespace Squidex.Infrastructure.EventSourcing } } }); + } - notification = eventNotifier.Subscribe(streamName => - { - if (streamRegex.IsMatch(streamName)) - { - timer.SkipCurrentDelay(); - } - }); + public void WakeUp() + { + timer.SkipCurrentDelay(); } public Task StopAsync() { - notification?.Dispose(); - return timer.StopAsync(); } } diff --git a/src/Squidex.Infrastructure/EventSourcing/RetrySubscription.cs b/src/Squidex.Infrastructure/EventSourcing/RetrySubscription.cs index d023eead5..60a9f5679 100644 --- a/src/Squidex.Infrastructure/EventSourcing/RetrySubscription.cs +++ b/src/Squidex.Infrastructure/EventSourcing/RetrySubscription.cs @@ -57,6 +57,11 @@ namespace Squidex.Infrastructure.EventSourcing currentSubscription = null; } + public void WakeUp() + { + currentSubscription?.WakeUp(); + } + private async Task HandleEventAsync(IEventSubscription subscription, StoredEvent storedEvent) { if (subscription == currentSubscription) diff --git a/src/Squidex.Infrastructure/EventSourcing/StoredEvent.cs b/src/Squidex.Infrastructure/EventSourcing/StoredEvent.cs index 3c93e21a4..97ee0f55c 100644 --- a/src/Squidex.Infrastructure/EventSourcing/StoredEvent.cs +++ b/src/Squidex.Infrastructure/EventSourcing/StoredEvent.cs @@ -9,14 +9,17 @@ namespace Squidex.Infrastructure.EventSourcing { public sealed class StoredEvent { + public string StreamName { get; } + public string EventPosition { get; } public long EventStreamNumber { get; } public EventData Data { get; } - public StoredEvent(string eventPosition, long eventStreamNumber, EventData data) + public StoredEvent(string streamName, string eventPosition, long eventStreamNumber, EventData data) { + Guard.NotNullOrEmpty(streamName, nameof(streamName)); Guard.NotNullOrEmpty(eventPosition, nameof(eventPosition)); Guard.NotNull(data, nameof(data)); @@ -24,6 +27,8 @@ namespace Squidex.Infrastructure.EventSourcing EventPosition = eventPosition; EventStreamNumber = eventStreamNumber; + + StreamName = streamName; } } } diff --git a/src/Squidex.Infrastructure/EventSourcing/WrongEventVersionException.cs b/src/Squidex.Infrastructure/EventSourcing/WrongEventVersionException.cs index ae88e64d5..0651467b8 100644 --- a/src/Squidex.Infrastructure/EventSourcing/WrongEventVersionException.cs +++ b/src/Squidex.Infrastructure/EventSourcing/WrongEventVersionException.cs @@ -37,6 +37,17 @@ namespace Squidex.Infrastructure.EventSourcing protected WrongEventVersionException(SerializationInfo info, StreamingContext context) : base(info, context) { + currentVersion = info.GetInt64(nameof(currentVersion)); + + expectedVersion = info.GetInt64(nameof(expectedVersion)); + } + + public override void GetObjectData(SerializationInfo info, StreamingContext context) + { + info.AddValue(nameof(currentVersion), currentVersion); + info.AddValue(nameof(expectedVersion), expectedVersion); + + base.GetObjectData(info, context); } private static string FormatMessage(long currentVersion, long expectedVersion) diff --git a/src/Squidex.Infrastructure/FileExtensions.cs b/src/Squidex.Infrastructure/FileExtensions.cs index 2b67ff008..885a47a9a 100644 --- a/src/Squidex.Infrastructure/FileExtensions.cs +++ b/src/Squidex.Infrastructure/FileExtensions.cs @@ -6,6 +6,8 @@ // ========================================================================== using System; +using System.Collections.Generic; +using System.Globalization; using System.IO; namespace Squidex.Infrastructure @@ -21,13 +23,26 @@ namespace Squidex.Infrastructure "TB" }; + private static readonly Dictionary UnifiedExtensions = new Dictionary + { + ["jpeg"] = "jpg" + }; + public static string FileType(this string fileName) { try { var fileInfo = new FileInfo(fileName); + var fileType = fileInfo.Extension.Substring(1).ToLowerInvariant(); - return fileInfo.Extension.Substring(1).ToLowerInvariant(); + if (UnifiedExtensions.TryGetValue(fileType, out var unified)) + { + return unified; + } + else + { + return fileType; + } } catch { @@ -62,7 +77,7 @@ namespace Squidex.Infrastructure u = Extensions.Length - 1; } - return $"{Math.Round(d, 1)} {Extensions[u]}"; + return $"{Math.Round(d, 1).ToString(CultureInfo.InvariantCulture)} {Extensions[u]}"; } } } diff --git a/src/Squidex.Infrastructure/Guard.cs b/src/Squidex.Infrastructure/Guard.cs index df6fcaedb..f4a5c8d53 100644 --- a/src/Squidex.Infrastructure/Guard.cs +++ b/src/Squidex.Infrastructure/Guard.cs @@ -116,7 +116,7 @@ namespace Squidex.Infrastructure { if (target.CompareTo(lower) < 0) { - throw new ArgumentException($"Value must be greater or equals than {lower}", parameterName); + throw new ArgumentException($"Value must be greater than or equal to {lower}", parameterName); } } @@ -136,13 +136,13 @@ namespace Squidex.Infrastructure { if (target.CompareTo(upper) > 0) { - throw new ArgumentException($"Value must be less or equals than {upper}", parameterName); + throw new ArgumentException($"Value must be less than or equal to {upper}", parameterName); } } [DebuggerStepThrough] [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void NotEmpty(ICollection enumerable, string parameterName) + public static void NotEmpty(IReadOnlyCollection enumerable, string parameterName) { NotNull(enumerable, parameterName); diff --git a/src/Squidex.Infrastructure/HashSet.cs b/src/Squidex.Infrastructure/HashSet.cs new file mode 100644 index 000000000..697d2bb39 --- /dev/null +++ b/src/Squidex.Infrastructure/HashSet.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; + +namespace Squidex.Infrastructure +{ + public static class HashSet + { + public static HashSet Of(params T[] items) + { + return new HashSet(items); + } + } +} diff --git a/src/Squidex.Infrastructure/Http/DumpFormatter.cs b/src/Squidex.Infrastructure/Http/DumpFormatter.cs index 5f0c0dff1..040e6ddc1 100644 --- a/src/Squidex.Infrastructure/Http/DumpFormatter.cs +++ b/src/Squidex.Infrastructure/Http/DumpFormatter.cs @@ -15,7 +15,17 @@ namespace Squidex.Infrastructure.Http { public static class DumpFormatter { - public static string BuildDump(HttpRequestMessage request, HttpResponseMessage response, string requestBody, string responseBody, TimeSpan elapsed, bool isTimeout) + public static string BuildDump(HttpRequestMessage request, HttpResponseMessage response, string responseBody) + { + return BuildDump(request, response, null, responseBody, TimeSpan.Zero, false); + } + + public static string BuildDump(HttpRequestMessage request, HttpResponseMessage response, string requestBody, string responseBody) + { + return BuildDump(request, response, requestBody, responseBody, TimeSpan.Zero, false); + } + + public static string BuildDump(HttpRequestMessage request, HttpResponseMessage response, string requestBody, string responseBody, TimeSpan elapsed, bool isTimeout = false) { var writer = new StringBuilder(); diff --git a/src/Squidex.Infrastructure/IResultList.cs b/src/Squidex.Infrastructure/IResultList.cs index 25687277d..69fee96c4 100644 --- a/src/Squidex.Infrastructure/IResultList.cs +++ b/src/Squidex.Infrastructure/IResultList.cs @@ -9,7 +9,7 @@ using System.Collections.Generic; namespace Squidex.Infrastructure { - public interface IResultList : IReadOnlyList + public interface IResultList : IReadOnlyList { long Total { get; } } diff --git a/src/Squidex.Infrastructure/Json/NamedStringIdConverter.cs b/src/Squidex.Infrastructure/Json/NamedStringIdConverter.cs index 32e113711..3076ef02c 100644 --- a/src/Squidex.Infrastructure/Json/NamedStringIdConverter.cs +++ b/src/Squidex.Infrastructure/Json/NamedStringIdConverter.cs @@ -32,7 +32,7 @@ namespace Squidex.Infrastructure.Json throw new JsonException("Named id must have more than 2 parts divided by colon."); } - return new NamedId(parts[0], string.Join(",", parts.Skip(1))); + return NamedId.Of(parts[0], string.Join(",", parts.Skip(1))); } } } diff --git a/src/Squidex.Infrastructure/Lazier.cs b/src/Squidex.Infrastructure/Lazier.cs new file mode 100644 index 000000000..aae97240c --- /dev/null +++ b/src/Squidex.Infrastructure/Lazier.cs @@ -0,0 +1,20 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Microsoft.Extensions.DependencyInjection; + +namespace Squidex.Infrastructure +{ + public sealed class Lazier : Lazy where T : class + { + public Lazier(IServiceProvider provider) + : base(provider.GetRequiredService) + { + } + } +} diff --git a/src/Squidex.Infrastructure/Log/Adapter/SemanticLogLogger.cs b/src/Squidex.Infrastructure/Log/Adapter/SemanticLogLogger.cs index 3a914d6cc..b978abf1b 100644 --- a/src/Squidex.Infrastructure/Log/Adapter/SemanticLogLogger.cs +++ b/src/Squidex.Infrastructure/Log/Adapter/SemanticLogLogger.cs @@ -6,6 +6,7 @@ // ========================================================================== using System; +using System.Collections.Generic; using Microsoft.Extensions.Logging; namespace Squidex.Infrastructure.Log.Adapter @@ -70,6 +71,22 @@ namespace Squidex.Infrastructure.Log.Adapter }); } + if (state is IReadOnlyList> parameters) + { + foreach (var kvp in parameters) + { + if (kvp.Value != null) + { + var key = kvp.Key.Trim('{', '}', ' '); + + if (key.Length > 2 && !string.Equals(key, "originalFormat", StringComparison.OrdinalIgnoreCase)) + { + writer.WriteProperty(key.ToCamelCase(), kvp.Value.ToString()); + } + } + } + } + if (exception != null) { writer.WriteException(exception); @@ -86,14 +103,5 @@ namespace Squidex.Infrastructure.Log.Adapter { return NoopDisposable.Instance; } - - private class NoopDisposable : IDisposable - { - public static readonly NoopDisposable Instance = new NoopDisposable(); - - public void Dispose() - { - } - } } } diff --git a/src/Squidex.Infrastructure/Log/Adapter/SemanticLogLoggerProvider.cs b/src/Squidex.Infrastructure/Log/Adapter/SemanticLogLoggerProvider.cs index 625843066..524145b0f 100644 --- a/src/Squidex.Infrastructure/Log/Adapter/SemanticLogLoggerProvider.cs +++ b/src/Squidex.Infrastructure/Log/Adapter/SemanticLogLoggerProvider.cs @@ -5,24 +5,47 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace Squidex.Infrastructure.Log.Adapter { public class SemanticLogLoggerProvider : ILoggerProvider { - private readonly ISemanticLog semanticLog; + private readonly IServiceProvider services; + private ISemanticLog log; - public SemanticLogLoggerProvider(ISemanticLog semanticLog) + public SemanticLogLoggerProvider(IServiceProvider services) { - Guard.NotNull(semanticLog, nameof(semanticLog)); + Guard.NotNull(services, nameof(services)); - this.semanticLog = semanticLog; + this.services = services; + } + + internal SemanticLogLoggerProvider(ISemanticLog log) + { + this.log = log; + } + + public static SemanticLogLoggerProvider ForTesting(ISemanticLog log) + { + return new SemanticLogLoggerProvider(log); } public ILogger CreateLogger(string categoryName) { - return new SemanticLogLogger(semanticLog.CreateScope(writer => + if (log == null && services != null) + { + log = services.GetService(typeof(ISemanticLog)) as ISemanticLog; + } + + if (log == null) + { + return NullLogger.Instance; + } + + return new SemanticLogLogger(log.CreateScope(writer => { writer.WriteProperty("category", categoryName); })); diff --git a/src/Squidex.Infrastructure/Log/ConsoleLogChannel.cs b/src/Squidex.Infrastructure/Log/ConsoleLogChannel.cs index 77ba0448e..bfc924469 100644 --- a/src/Squidex.Infrastructure/Log/ConsoleLogChannel.cs +++ b/src/Squidex.Infrastructure/Log/ConsoleLogChannel.cs @@ -13,6 +13,12 @@ namespace Squidex.Infrastructure.Log public sealed class ConsoleLogChannel : ILogChannel, IDisposable { private readonly ConsoleLogProcessor processor = new ConsoleLogProcessor(); + private readonly bool useColors; + + public ConsoleLogChannel(bool useColors = false) + { + this.useColors = useColors; + } public void Dispose() { @@ -23,13 +29,16 @@ namespace Squidex.Infrastructure.Log { var color = 0; - if (logLevel == SemanticLogLevel.Warning) - { - color = 0xffff00; - } - else if (logLevel >= SemanticLogLevel.Error) + if (useColors) { - color = 0xff0000; + if (logLevel == SemanticLogLevel.Warning) + { + color = 0xffff00; + } + else if (logLevel >= SemanticLogLevel.Error) + { + color = 0xff0000; + } } processor.EnqueueMessage(new LogMessageEntry { Message = message, Color = color }); diff --git a/src/Squidex.Infrastructure/Log/Internal/AnsiLogConsole.cs b/src/Squidex.Infrastructure/Log/Internal/AnsiLogConsole.cs index fffabfcd4..cb8ec5acc 100644 --- a/src/Squidex.Infrastructure/Log/Internal/AnsiLogConsole.cs +++ b/src/Squidex.Infrastructure/Log/Internal/AnsiLogConsole.cs @@ -18,6 +18,10 @@ namespace Squidex.Infrastructure.Log.Internal this.logToStdError = logToStdError; } + public void Reset() + { + } + public void WriteLine(int color, string message) { if (color != 0 && logToStdError) diff --git a/src/Squidex.Infrastructure/Log/Internal/ConsoleLogProcessor.cs b/src/Squidex.Infrastructure/Log/Internal/ConsoleLogProcessor.cs index b536f509f..06c76e40e 100644 --- a/src/Squidex.Infrastructure/Log/Internal/ConsoleLogProcessor.cs +++ b/src/Squidex.Infrastructure/Log/Internal/ConsoleLogProcessor.cs @@ -86,6 +86,10 @@ namespace Squidex.Infrastructure.Log.Internal { Debug.WriteLine($"Failed to shutdown log queue grateful: {ex}."); } + finally + { + console.Reset(); + } } } } diff --git a/src/Squidex.Infrastructure/Log/Internal/IConsole.cs b/src/Squidex.Infrastructure/Log/Internal/IConsole.cs index d5c45b274..c996fe108 100644 --- a/src/Squidex.Infrastructure/Log/Internal/IConsole.cs +++ b/src/Squidex.Infrastructure/Log/Internal/IConsole.cs @@ -10,5 +10,7 @@ namespace Squidex.Infrastructure.Log.Internal public interface IConsole { void WriteLine(int color, string message); + + void Reset(); } } diff --git a/src/Squidex.Infrastructure/Log/Internal/WindowsLogConsole.cs b/src/Squidex.Infrastructure/Log/Internal/WindowsLogConsole.cs index 7275761e2..3a0b136f6 100644 --- a/src/Squidex.Infrastructure/Log/Internal/WindowsLogConsole.cs +++ b/src/Squidex.Infrastructure/Log/Internal/WindowsLogConsole.cs @@ -18,6 +18,11 @@ namespace Squidex.Infrastructure.Log.Internal this.logToStdError = logToStdError; } + public void Reset() + { + Console.ResetColor(); + } + public void WriteLine(int color, string message) { if (color != 0) diff --git a/src/Squidex.Infrastructure/Log/JsonLogWriter.cs b/src/Squidex.Infrastructure/Log/JsonLogWriter.cs index 07851ac5b..7d9bdf21f 100644 --- a/src/Squidex.Infrastructure/Log/JsonLogWriter.cs +++ b/src/Squidex.Infrastructure/Log/JsonLogWriter.cs @@ -56,7 +56,7 @@ namespace Squidex.Infrastructure.Log IArrayWriter IArrayWriter.WriteValue(DateTime value) { - jsonWriter.WriteValue(value.ToString("o", CultureInfo.InvariantCulture)); + jsonWriter.WriteValue(value.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)); return this; } diff --git a/src/Squidex.Infrastructure/Log/NoopDisposable.cs b/src/Squidex.Infrastructure/Log/NoopDisposable.cs new file mode 100644 index 000000000..60d95e7a0 --- /dev/null +++ b/src/Squidex.Infrastructure/Log/NoopDisposable.cs @@ -0,0 +1,24 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; + +namespace Squidex.Infrastructure.Log +{ + public sealed class NoopDisposable : IDisposable + { + public static readonly NoopDisposable Instance = new NoopDisposable(); + + private NoopDisposable() + { + } + + public void Dispose() + { + } + } +} diff --git a/src/Squidex.Infrastructure/Log/Profiler.cs b/src/Squidex.Infrastructure/Log/Profiler.cs new file mode 100644 index 000000000..e420f2dea --- /dev/null +++ b/src/Squidex.Infrastructure/Log/Profiler.cs @@ -0,0 +1,73 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Runtime.CompilerServices; +using System.Threading; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Infrastructure.Log +{ + public static class Profiler + { + private static readonly AsyncLocal LocalSession = new AsyncLocal(); + private static readonly AsyncLocalCleaner Cleaner; + + public static ProfilerSession Session + { + get { return LocalSession.Value; } + } + + static Profiler() + { + Cleaner = new AsyncLocalCleaner(LocalSession); + } + + public static IDisposable StartSession() + { + LocalSession.Value = new ProfilerSession(); + + return Cleaner; + } + + public static IDisposable TraceMethod(Type type, [CallerMemberName] string memberName = null) + { + return Trace($"{type.Name}/{memberName}"); + } + + public static IDisposable TraceMethod([CallerMemberName] string memberName = null) + { + return Trace($"{typeof(T).Name}/{memberName}"); + } + + public static IDisposable TraceMethod(string objectName, [CallerMemberName] string memberName = null) + { + return Trace($"{objectName}/{memberName}"); + } + + public static IDisposable Trace(string key) + { + Guard.NotNull(key, nameof(key)); + + var session = LocalSession.Value; + + if (session == null) + { + return NoopDisposable.Instance; + } + + var watch = ValueStopwatch.StartNew(); + + return new DelegateDisposable(() => + { + var elapsedMs = watch.Stop(); + + session.Measured(key, elapsedMs); + }); + } + } +} diff --git a/src/Squidex.Infrastructure/Log/ProfilerSession.cs b/src/Squidex.Infrastructure/Log/ProfilerSession.cs new file mode 100644 index 000000000..9249b630e --- /dev/null +++ b/src/Squidex.Infrastructure/Log/ProfilerSession.cs @@ -0,0 +1,58 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Concurrent; + +namespace Squidex.Infrastructure.Log +{ + public sealed class ProfilerSession + { + private struct ProfilerItem + { + public long Total; + public long Count; + } + + private readonly ConcurrentDictionary traces = new ConcurrentDictionary(); + + public void Measured(string name, long elapsed) + { + Guard.NotNullOrEmpty(name, nameof(name)); + + traces.AddOrUpdate(name, x => + { + return new ProfilerItem { Total = elapsed, Count = 1 }; + }, + (x, result) => + { + result.Total += elapsed; + result.Count++; + + return result; + }); + } + + public void Write(IObjectWriter writer) + { + Guard.NotNull(writer, nameof(writer)); + + if (traces.Count > 0) + { + writer.WriteObject("profiler", p => + { + foreach (var kvp in traces) + { + p.WriteObject(kvp.Key, k => k + .WriteProperty("elapsedMsTotal", kvp.Value.Total) + .WriteProperty("elapsedMsAvg", kvp.Value.Total / kvp.Value.Count) + .WriteProperty("count", kvp.Value.Count)); + } + }); + } + } + } +} diff --git a/src/Squidex.Infrastructure/Log/SemanticLogExtensions.cs b/src/Squidex.Infrastructure/Log/SemanticLogExtensions.cs index a10acea8c..414bf298c 100644 --- a/src/Squidex.Infrastructure/Log/SemanticLogExtensions.cs +++ b/src/Squidex.Infrastructure/Log/SemanticLogExtensions.cs @@ -6,7 +6,6 @@ // ========================================================================== using System; -using System.Diagnostics; namespace Squidex.Infrastructure.Log { @@ -17,31 +16,16 @@ namespace Squidex.Infrastructure.Log log.Log(SemanticLogLevel.Trace, objectWriter); } - public static IDisposable MeasureTrace(this ISemanticLog log, Action objectWriter) - { - return new TimeMeasurer(log, SemanticLogLevel.Trace, objectWriter); - } - public static void LogDebug(this ISemanticLog log, Action objectWriter) { log.Log(SemanticLogLevel.Debug, objectWriter); } - public static IDisposable MeasureDebug(this ISemanticLog log, Action objectWriter) - { - return new TimeMeasurer(log, SemanticLogLevel.Debug, objectWriter); - } - public static void LogInformation(this ISemanticLog log, Action objectWriter) { log.Log(SemanticLogLevel.Information, objectWriter); } - public static IDisposable MeasureInformation(this ISemanticLog log, Action objectWriter) - { - return new TimeMeasurer(log, SemanticLogLevel.Information, objectWriter); - } - public static void LogWarning(this ISemanticLog log, Action objectWriter) { log.Log(SemanticLogLevel.Warning, objectWriter); @@ -92,32 +76,36 @@ namespace Squidex.Infrastructure.Log }); } - private sealed class TimeMeasurer : IDisposable + public static IDisposable MeasureTrace(this ISemanticLog log, Action objectWriter) + { + return log.Measure(SemanticLogLevel.Trace, objectWriter); + } + + public static IDisposable MeasureDebug(this ISemanticLog log, Action objectWriter) { - private readonly Stopwatch watch = Stopwatch.StartNew(); - private readonly SemanticLogLevel logLevel; - private readonly Action objectWriter; - private readonly ISemanticLog log; + return log.Measure(SemanticLogLevel.Debug, objectWriter); + } - public TimeMeasurer(ISemanticLog log, SemanticLogLevel logLevel, Action objectWriter) - { - this.logLevel = logLevel; - this.log = log; + public static IDisposable MeasureInformation(this ISemanticLog log, Action objectWriter) + { + return log.Measure(SemanticLogLevel.Information, objectWriter); + } - this.objectWriter = objectWriter; - } + private static IDisposable Measure(this ISemanticLog log, SemanticLogLevel logLevel, Action objectWriter) + { + var watch = ValueStopwatch.StartNew(); - public void Dispose() + return new DelegateDisposable(() => { - watch.Stop(); + var elapsedMs = watch.Stop(); log.Log(logLevel, writer => { objectWriter?.Invoke(writer); - writer.WriteProperty("elapsedMs", watch.ElapsedMilliseconds); + writer.WriteProperty("elapsedMs", elapsedMs); }); - } + }); } } } diff --git a/src/Squidex.Infrastructure/NamedId.cs b/src/Squidex.Infrastructure/NamedId.cs index e8f99f6d4..e0c8106be 100644 --- a/src/Squidex.Infrastructure/NamedId.cs +++ b/src/Squidex.Infrastructure/NamedId.cs @@ -1,70 +1,17 @@ // ========================================================================== // Squidex Headless CMS // ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) +// Copyright (c) Squidex UG (haftungsbeschraenkt) // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; -using System.Linq; - namespace Squidex.Infrastructure { - public delegate bool Parser(string input, out T result); - - public sealed class NamedId : IEquatable> + public static class NamedId { - public T Id { get; } - - public string Name { get; } - - public NamedId(T id, string name) + public static NamedId Of(T id, string name) { - Guard.NotNull(id, nameof(id)); - Guard.NotNull(name, nameof(name)); - - Id = id; - - Name = name; - } - - public override string ToString() - { - return $"{Id},{Name}"; - } - - public override bool Equals(object obj) - { - return Equals(obj as NamedId); - } - - public bool Equals(NamedId other) - { - return other != null && (ReferenceEquals(this, other) || (Id.Equals(other.Id) && Name.Equals(other.Name))); - } - - public override int GetHashCode() - { - return (Id.GetHashCode() * 397) ^ Name.GetHashCode(); - } - - public static NamedId Parse(string value, Parser parser) - { - Guard.NotNull(value, nameof(value)); - - var parts = value.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - - if (parts.Length < 2) - { - throw new ArgumentException("Named id must have more than 2 parts divided by commata."); - } - - if (!parser(parts[0], out var id)) - { - throw new ArgumentException("Named id must be a valid guid."); - } - - return new NamedId(id, string.Join(",", parts.Skip(1))); + return new NamedId(id, name); } } } diff --git a/src/Squidex.Infrastructure/NamedId{T}.cs b/src/Squidex.Infrastructure/NamedId{T}.cs new file mode 100644 index 000000000..e8f99f6d4 --- /dev/null +++ b/src/Squidex.Infrastructure/NamedId{T}.cs @@ -0,0 +1,70 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Linq; + +namespace Squidex.Infrastructure +{ + public delegate bool Parser(string input, out T result); + + public sealed class NamedId : IEquatable> + { + public T Id { get; } + + public string Name { get; } + + public NamedId(T id, string name) + { + Guard.NotNull(id, nameof(id)); + Guard.NotNull(name, nameof(name)); + + Id = id; + + Name = name; + } + + public override string ToString() + { + return $"{Id},{Name}"; + } + + public override bool Equals(object obj) + { + return Equals(obj as NamedId); + } + + public bool Equals(NamedId other) + { + return other != null && (ReferenceEquals(this, other) || (Id.Equals(other.Id) && Name.Equals(other.Name))); + } + + public override int GetHashCode() + { + return (Id.GetHashCode() * 397) ^ Name.GetHashCode(); + } + + public static NamedId Parse(string value, Parser parser) + { + Guard.NotNull(value, nameof(value)); + + var parts = value.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + + if (parts.Length < 2) + { + throw new ArgumentException("Named id must have more than 2 parts divided by commata."); + } + + if (!parser(parts[0], out var id)) + { + throw new ArgumentException("Named id must be a valid guid."); + } + + return new NamedId(id, string.Join(",", parts.Skip(1))); + } + } +} diff --git a/src/Squidex.Infrastructure/Orleans/Bootstrap.cs b/src/Squidex.Infrastructure/Orleans/Bootstrap.cs new file mode 100644 index 000000000..5a3bb7a19 --- /dev/null +++ b/src/Squidex.Infrastructure/Orleans/Bootstrap.cs @@ -0,0 +1,49 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading; +using System.Threading.Tasks; +using Orleans; +using Orleans.Runtime; + +namespace Squidex.Infrastructure.Orleans +{ + public sealed class Bootstrap : IStartupTask where T : IBackgroundGrain + { + private const int NumTries = 10; + private readonly IGrainFactory grainFactory; + + public Bootstrap(IGrainFactory grainFactory) + { + Guard.NotNull(grainFactory, nameof(grainFactory)); + + this.grainFactory = grainFactory; + } + + public async Task Execute(CancellationToken cancellationToken) + { + for (var i = 1; i <= NumTries; i++) + { + try + { + var grain = grainFactory.GetGrain(SingleGrain.Id); + + await grain.ActivateAsync(); + + return; + } + catch (OrleansException) + { + if (i == NumTries) + { + throw; + } + } + } + } + } +} diff --git a/src/Squidex.Infrastructure/Orleans/GrainOfGuid.cs b/src/Squidex.Infrastructure/Orleans/GrainOfGuid.cs new file mode 100644 index 000000000..0e6e50e22 --- /dev/null +++ b/src/Squidex.Infrastructure/Orleans/GrainOfGuid.cs @@ -0,0 +1,27 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Orleans; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Infrastructure.Orleans +{ + public abstract class GrainOfGuid : Grain + { + public sealed override Task OnActivateAsync() + { + return OnActivateAsync(this.GetPrimaryKey()); + } + + public virtual Task OnActivateAsync(Guid key) + { + return TaskHelper.Done; + } + } +} diff --git a/src/Squidex.Infrastructure/Orleans/GrainOfString.cs b/src/Squidex.Infrastructure/Orleans/GrainOfString.cs new file mode 100644 index 000000000..3d6b4e089 --- /dev/null +++ b/src/Squidex.Infrastructure/Orleans/GrainOfString.cs @@ -0,0 +1,26 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using Orleans; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Infrastructure.Orleans +{ + public abstract class GrainOfString : Grain + { + public sealed override Task OnActivateAsync() + { + return OnActivateAsync(this.GetPrimaryKeyString()); + } + + public virtual Task OnActivateAsync(string key) + { + return TaskHelper.Done; + } + } +} diff --git a/src/Squidex.Infrastructure/Orleans/IBackgroundGrain.cs b/src/Squidex.Infrastructure/Orleans/IBackgroundGrain.cs new file mode 100644 index 000000000..e30295b8a --- /dev/null +++ b/src/Squidex.Infrastructure/Orleans/IBackgroundGrain.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using Orleans; + +namespace Squidex.Infrastructure.Orleans +{ + public interface IBackgroundGrain : IGrainWithStringKey + { + Task ActivateAsync(); + } +} diff --git a/src/Squidex.Infrastructure/Orleans/J.cs b/src/Squidex.Infrastructure/Orleans/J.cs new file mode 100644 index 000000000..9f89f09d6 --- /dev/null +++ b/src/Squidex.Infrastructure/Orleans/J.cs @@ -0,0 +1,32 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using Newtonsoft.Json; + +namespace Squidex.Infrastructure.Orleans +{ + public static class J + { + internal static readonly JsonSerializer DefaultSerializer = JsonSerializer.CreateDefault(); + + public static J AsJ(this T value) + { + return new J(value); + } + + public static J Of(T value) + { + return value; + } + + public static Task> AsTask(T value) + { + return Task.FromResult>(value); + } + } +} diff --git a/src/Squidex.Infrastructure/Orleans/J{T}.cs b/src/Squidex.Infrastructure/Orleans/J{T}.cs new file mode 100644 index 000000000..e39b12664 --- /dev/null +++ b/src/Squidex.Infrastructure/Orleans/J{T}.cs @@ -0,0 +1,103 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; +using Orleans.CodeGeneration; +using Orleans.Concurrency; +using Orleans.Serialization; +using Squidex.Infrastructure.Log; + +namespace Squidex.Infrastructure.Orleans +{ + [Immutable] + public struct J + { + public T Value { get; } + + [JsonConstructor] + public J(T value) + { + Value = value; + } + + public static implicit operator T(J value) + { + return value.Value; + } + + public static implicit operator J(T d) + { + return new J(d); + } + + public override string ToString() + { + return Value?.ToString() ?? string.Empty; + } + + public static Task> AsTask(T value) + { + return Task.FromResult>(value); + } + + [CopierMethod] + public static object Copy(object input, ICopyContext context) + { + return input; + } + + [SerializerMethod] + public static void Serialize(object input, ISerializationContext context, Type expected) + { + using (Profiler.TraceMethod(nameof(J))) + { + var jsonSerializer = GetSerializer(context); + + var stream = new StreamWriterWrapper(context.StreamWriter); + + using (var writer = new JsonTextWriter(new StreamWriter(stream))) + { + jsonSerializer.Serialize(writer, input); + + writer.Flush(); + } + } + } + + [DeserializerMethod] + public static object Deserialize(Type expected, IDeserializationContext context) + { + using (Profiler.TraceMethod(nameof(J))) + { + var jsonSerializer = GetSerializer(context); + + var stream = new StreamReaderWrapper(context.StreamReader); + + using (var reader = new JsonTextReader(new StreamReader(stream))) + { + return jsonSerializer.Deserialize(reader, expected); + } + } + } + + private static JsonSerializer GetSerializer(ISerializerContext context) + { + try + { + return context?.ServiceProvider?.GetService() ?? J.DefaultSerializer; + } + catch + { + return J.DefaultSerializer; + } + } + } +} diff --git a/src/Squidex.Infrastructure/Orleans/LocalCacheFilter.cs b/src/Squidex.Infrastructure/Orleans/LocalCacheFilter.cs new file mode 100644 index 000000000..842a0dad9 --- /dev/null +++ b/src/Squidex.Infrastructure/Orleans/LocalCacheFilter.cs @@ -0,0 +1,41 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Orleans; +using Squidex.Infrastructure.Caching; + +namespace Squidex.Infrastructure.Orleans +{ + public sealed class LocalCacheFilter : IIncomingGrainCallFilter + { + private readonly ILocalCache localCache; + + public LocalCacheFilter(ILocalCache localCache) + { + Guard.NotNull(localCache, nameof(localCache)); + + this.localCache = localCache; + } + + public async Task Invoke(IIncomingGrainCallContext context) + { + if (!context.Grain.GetType().Namespace.StartsWith("Orleans", StringComparison.OrdinalIgnoreCase)) + { + using (localCache.StartContext()) + { + await context.Invoke(); + } + } + else + { + await context.Invoke(); + } + } + } +} diff --git a/src/Squidex.Infrastructure/Orleans/SingleGrain.cs b/src/Squidex.Infrastructure/Orleans/SingleGrain.cs new file mode 100644 index 000000000..66a9fb356 --- /dev/null +++ b/src/Squidex.Infrastructure/Orleans/SingleGrain.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Infrastructure.Orleans +{ + public static class SingleGrain + { + public const string Id = "Default"; + } +} diff --git a/src/Squidex.Infrastructure/Orleans/StreamReaderWrapper.cs b/src/Squidex.Infrastructure/Orleans/StreamReaderWrapper.cs new file mode 100644 index 000000000..9284b2a9f --- /dev/null +++ b/src/Squidex.Infrastructure/Orleans/StreamReaderWrapper.cs @@ -0,0 +1,88 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; +using Orleans.Serialization; + +namespace Squidex.Infrastructure.Orleans +{ + internal sealed class StreamReaderWrapper : Stream + { + private readonly IBinaryTokenStreamReader reader; + + public override bool CanRead + { + get { return true; } + } + + public override bool CanSeek + { + get { return false; } + } + + public override bool CanWrite + { + get { return false; } + } + + public override long Length + { + get { return reader.Length; } + } + + public override long Position + { + get + { + return reader.CurrentPosition; + } + set + { + throw new NotSupportedException(); + } + } + + public StreamReaderWrapper(IBinaryTokenStreamReader reader) + { + this.reader = reader; + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + var bytesLeft = reader.Length - reader.CurrentPosition; + + if (bytesLeft < count) + { + count = bytesLeft; + } + + reader.ReadByteArray(buffer, offset, count); + + return count; + } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotSupportedException(); + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + } +} diff --git a/src/Squidex.Infrastructure/Orleans/StreamWriterWrapper.cs b/src/Squidex.Infrastructure/Orleans/StreamWriterWrapper.cs new file mode 100644 index 000000000..fe9707a47 --- /dev/null +++ b/src/Squidex.Infrastructure/Orleans/StreamWriterWrapper.cs @@ -0,0 +1,79 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; +using Orleans.Serialization; + +namespace Squidex.Infrastructure.Orleans +{ + internal sealed class StreamWriterWrapper : Stream + { + private readonly IBinaryTokenStreamWriter writer; + + public override bool CanRead + { + get { return false; } + } + + public override bool CanSeek + { + get { return false; } + } + + public override bool CanWrite + { + get { return true; } + } + + public override long Length + { + get { return writer.CurrentOffset; } + } + + public override long Position + { + get + { + return writer.CurrentOffset; + } + set + { + throw new NotSupportedException(); + } + } + + public StreamWriterWrapper(IBinaryTokenStreamWriter writer) + { + this.writer = writer; + } + + public override void Flush() + { + } + + public override void Write(byte[] buffer, int offset, int count) + { + writer.Write(buffer, offset, count); + } + + public override int Read(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotSupportedException(); + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + } +} diff --git a/src/Squidex.Infrastructure/Queries/FilterBuilder.cs b/src/Squidex.Infrastructure/Queries/FilterBuilder.cs new file mode 100644 index 000000000..21eb97cfa --- /dev/null +++ b/src/Squidex.Infrastructure/Queries/FilterBuilder.cs @@ -0,0 +1,80 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Linq; +using NodaTime; + +namespace Squidex.Infrastructure.Queries +{ + public static class FilterBuilder + { + public static FilterJunction And(params FilterNode[] operands) + { + return new FilterJunction(FilterJunctionType.And, operands); + } + + public static FilterJunction Or(params FilterNode[] operands) + { + return new FilterJunction(FilterJunctionType.Or, operands); + } + + public static FilterComparison Eq(string path, string value) + { + return Binary(path, FilterOperator.Equals, value); + } + + public static FilterComparison Eq(string path, bool value) + { + return Binary(path, FilterOperator.Equals, value); + } + + public static FilterComparison Eq(string path, long value) + { + return Binary(path, FilterOperator.Equals, value); + } + + public static FilterComparison Eq(string path, int value) + { + return Binary(path, FilterOperator.Equals, value); + } + + public static FilterComparison Eq(string path, Instant value) + { + return Binary(path, FilterOperator.Equals, value); + } + + public static FilterComparison In(string path, params long[] value) + { + return new FilterComparison(path.Split('.', '/'), FilterOperator.In, new FilterValue(value.ToList())); + } + + private static FilterComparison Binary(string path, FilterOperator @operator, string value) + { + return new FilterComparison(path.Split('.', '/'), @operator, new FilterValue(value)); + } + + private static FilterComparison Binary(string path, FilterOperator @operator, bool value) + { + return new FilterComparison(path.Split('.', '/'), @operator, new FilterValue(value)); + } + + private static FilterComparison Binary(string path, FilterOperator @operator, long value) + { + return new FilterComparison(path.Split('.', '/'), @operator, new FilterValue(value)); + } + + private static FilterComparison Binary(string path, FilterOperator @operator, int value) + { + return new FilterComparison(path.Split('.', '/'), @operator, new FilterValue(value)); + } + + private static FilterComparison Binary(string path, FilterOperator @operator, Instant value) + { + return new FilterComparison(path.Split('.', '/'), @operator, new FilterValue(value)); + } + } +} diff --git a/src/Squidex.Infrastructure/Queries/FilterComparison.cs b/src/Squidex.Infrastructure/Queries/FilterComparison.cs new file mode 100644 index 000000000..81db90bc2 --- /dev/null +++ b/src/Squidex.Infrastructure/Queries/FilterComparison.cs @@ -0,0 +1,68 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; + +namespace Squidex.Infrastructure.Queries +{ + public sealed class FilterComparison : FilterNode + { + public IReadOnlyList Lhs { get; } + + public FilterOperator Operator { get; } + + public FilterValue Rhs { get; } + + public FilterComparison(IReadOnlyList lhs, FilterOperator @operator, FilterValue rhs) + { + Guard.NotNull(lhs, nameof(lhs)); + Guard.NotEmpty(lhs, nameof(lhs)); + Guard.Enum(@operator, nameof(@operator)); + + Lhs = lhs; + Rhs = rhs; + + Operator = @operator; + } + + public override T Accept(FilterNodeVisitor visitor) + { + return visitor.Visit(this); + } + + public override string ToString() + { + var path = string.Join(".", Lhs); + + switch (Operator) + { + case FilterOperator.Contains: + return $"contains({path}, {Rhs})"; + case FilterOperator.EndsWith: + return $"endsWith({path}, {Rhs})"; + case FilterOperator.StartsWith: + return $"startsWith({path}, {Rhs})"; + case FilterOperator.Equals: + return $"{path} == {Rhs}"; + case FilterOperator.NotEquals: + return $"{path} != {Rhs}"; + case FilterOperator.GreaterThan: + return $"{path} > {Rhs}"; + case FilterOperator.GreaterThanOrEqual: + return $"{path} >= {Rhs}"; + case FilterOperator.LessThan: + return $"{path} < {Rhs}"; + case FilterOperator.LessThanOrEqual: + return $"{path} <= {Rhs}"; + case FilterOperator.In: + return $"{path} in {Rhs}"; + default: + return string.Empty; + } + } + } +} \ No newline at end of file diff --git a/src/Squidex.Infrastructure/Queries/FilterJunction.cs b/src/Squidex.Infrastructure/Queries/FilterJunction.cs new file mode 100644 index 000000000..08c2bf2a9 --- /dev/null +++ b/src/Squidex.Infrastructure/Queries/FilterJunction.cs @@ -0,0 +1,45 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Linq; + +namespace Squidex.Infrastructure.Queries +{ + public sealed class FilterJunction : FilterNode + { + public IReadOnlyList Operands { get; } + + public FilterJunctionType JunctionType { get; } + + public FilterJunction(FilterJunctionType junctionType, IReadOnlyList operands) + { + Guard.NotNull(operands, nameof(operands)); + Guard.GreaterEquals(operands.Count, 2, nameof(operands.Count)); + Guard.Enum(junctionType, nameof(junctionType)); + + Operands = operands; + + JunctionType = junctionType; + } + + public FilterJunction(FilterJunctionType junctionType, params FilterNode[] operands) + : this(junctionType, operands?.ToList()) + { + } + + public override T Accept(FilterNodeVisitor visitor) + { + return visitor.Visit(this); + } + + public override string ToString() + { + return $"({string.Join(JunctionType == FilterJunctionType.And ? " && " : " || ", Operands)})"; + } + } +} diff --git a/src/Squidex.Infrastructure/Queries/FilterJunctionType.cs b/src/Squidex.Infrastructure/Queries/FilterJunctionType.cs new file mode 100644 index 000000000..c9c8b8289 --- /dev/null +++ b/src/Squidex.Infrastructure/Queries/FilterJunctionType.cs @@ -0,0 +1,15 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Infrastructure.Queries +{ + public enum FilterJunctionType + { + And, + Or + } +} \ No newline at end of file diff --git a/src/Squidex.Infrastructure/Queries/FilterNegate.cs b/src/Squidex.Infrastructure/Queries/FilterNegate.cs new file mode 100644 index 000000000..650170444 --- /dev/null +++ b/src/Squidex.Infrastructure/Queries/FilterNegate.cs @@ -0,0 +1,31 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Infrastructure.Queries +{ + public sealed class FilterNegate : FilterNode + { + public FilterNode Operand { get; } + + public FilterNegate(FilterNode operand) + { + Guard.NotNull(operand, nameof(operand)); + + Operand = operand; + } + + public override T Accept(FilterNodeVisitor visitor) + { + return visitor.Visit(this); + } + + public override string ToString() + { + return $"!({Operand})"; + } + } +} diff --git a/src/Squidex.Infrastructure/Queries/FilterNode.cs b/src/Squidex.Infrastructure/Queries/FilterNode.cs new file mode 100644 index 000000000..61f348538 --- /dev/null +++ b/src/Squidex.Infrastructure/Queries/FilterNode.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Infrastructure.Queries +{ + public abstract class FilterNode + { + public abstract T Accept(FilterNodeVisitor visitor); + } +} diff --git a/src/Squidex.Infrastructure/Queries/FilterNodeVisitor.cs b/src/Squidex.Infrastructure/Queries/FilterNodeVisitor.cs new file mode 100644 index 000000000..30ef01741 --- /dev/null +++ b/src/Squidex.Infrastructure/Queries/FilterNodeVisitor.cs @@ -0,0 +1,31 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; + +#pragma warning disable RECS0083 // Shows NotImplementedException throws in the quick task bar + +namespace Squidex.Infrastructure.Queries +{ + public abstract class FilterNodeVisitor + { + public virtual T Visit(FilterComparison nodeIn) + { + throw new NotImplementedException(); + } + + public virtual T Visit(FilterJunction nodeIn) + { + throw new NotImplementedException(); + } + + public virtual T Visit(FilterNegate nodeIn) + { + throw new NotImplementedException(); + } + } +} \ No newline at end of file diff --git a/src/Squidex.Infrastructure/Queries/FilterOperator.cs b/src/Squidex.Infrastructure/Queries/FilterOperator.cs new file mode 100644 index 000000000..e1de05e93 --- /dev/null +++ b/src/Squidex.Infrastructure/Queries/FilterOperator.cs @@ -0,0 +1,23 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Infrastructure.Queries +{ + public enum FilterOperator + { + Contains, + EndsWith, + Equals, + GreaterThan, + GreaterThanOrEqual, + In, + LessThan, + LessThanOrEqual, + NotEquals, + StartsWith + } +} \ No newline at end of file diff --git a/src/Squidex.Infrastructure/Queries/FilterValue.cs b/src/Squidex.Infrastructure/Queries/FilterValue.cs new file mode 100644 index 000000000..239a4ef5f --- /dev/null +++ b/src/Squidex.Infrastructure/Queries/FilterValue.cs @@ -0,0 +1,150 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using NodaTime; + +namespace Squidex.Infrastructure.Queries +{ + public sealed class FilterValue + { + public static readonly FilterValue Null = new FilterValue(null, FilterValueType.Null, false); + + public object Value { get; } + + public FilterValueType ValueType { get; } + public bool IsList { get; } + + public FilterValue(Guid value) + : this(value, FilterValueType.Guid, false) + { + } + + public FilterValue(Instant value) + : this(value, FilterValueType.Instant, false) + { + } + + public FilterValue(bool value) + : this(value, FilterValueType.Boolean, false) + { + } + + public FilterValue(float value) + : this(value, FilterValueType.Single, false) + { + } + + public FilterValue(double value) + : this(value, FilterValueType.Double, false) + { + } + + public FilterValue(int value) + : this(value, FilterValueType.Int32, false) + { + } + + public FilterValue(long value) + : this(value, FilterValueType.Int64, false) + { + } + + public FilterValue(string value) + : this(value, FilterValueType.String, false) + { + Guard.NotNull(value, nameof(value)); + } + + public FilterValue(List value) + : this(value, FilterValueType.Guid, true) + { + Guard.NotNull(value, nameof(value)); + } + + public FilterValue(List value) + : this(value, FilterValueType.Instant, true) + { + Guard.NotNull(value, nameof(value)); + } + + public FilterValue(List value) + : this(value, FilterValueType.Boolean, true) + { + Guard.NotNull(value, nameof(value)); + } + + public FilterValue(List value) + : this(value, FilterValueType.Single, true) + { + Guard.NotNull(value, nameof(value)); + } + + public FilterValue(List value) + : this(value, FilterValueType.Double, true) + { + Guard.NotNull(value, nameof(value)); + } + + public FilterValue(List value) + : this(value, FilterValueType.Int32, true) + { + Guard.NotNull(value, nameof(value)); + } + + public FilterValue(List value) + : this(value, FilterValueType.Int64, true) + { + Guard.NotNull(value, nameof(value)); + } + + public FilterValue(List value) + : this(value, FilterValueType.String, true) + { + Guard.NotNull(value, nameof(value)); + } + + private FilterValue(object value, FilterValueType valueType, bool isList) + { + Value = value; + ValueType = valueType; + + IsList = isList; + } + + public override string ToString() + { + if (Value is IList list) + { + return $"[{string.Join(", ", list.OfType().Select(ToString).ToArray())}]"; + } + else + { + return ToString(Value); + } + } + + private string ToString(object value) + { + if (ValueType == FilterValueType.String) + { + return $"'{value.ToString().Replace("'", "\\'")}'"; + } + else if (value == null) + { + return "null"; + } + else + { + return value.ToString(); + } + } + } +} diff --git a/src/Squidex.Infrastructure/Queries/FilterValueType.cs b/src/Squidex.Infrastructure/Queries/FilterValueType.cs new file mode 100644 index 000000000..cdb64a139 --- /dev/null +++ b/src/Squidex.Infrastructure/Queries/FilterValueType.cs @@ -0,0 +1,22 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Infrastructure.Queries +{ + public enum FilterValueType + { + Boolean, + Guid, + Double, + Instant, + Int32, + Int64, + Single, + String, + Null + } +} diff --git a/src/Squidex.Infrastructure/Queries/OData/ConstantVisitor.cs b/src/Squidex.Infrastructure/Queries/OData/ConstantVisitor.cs new file mode 100644 index 000000000..e430d1655 --- /dev/null +++ b/src/Squidex.Infrastructure/Queries/OData/ConstantVisitor.cs @@ -0,0 +1,30 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.OData.UriParser; + +namespace Squidex.Infrastructure.Queries.OData +{ + public sealed class ConstantVisitor : QueryNodeVisitor + { + private static readonly ConstantVisitor Instance = new ConstantVisitor(); + + private ConstantVisitor() + { + } + + public static object Visit(QueryNode node) + { + return node.Accept(Instance); + } + + public override object Visit(ConstantNode nodeIn) + { + return nodeIn.Value; + } + } +} diff --git a/src/Squidex.Infrastructure/Queries/OData/ConstantWithTypeVisitor.cs b/src/Squidex.Infrastructure/Queries/OData/ConstantWithTypeVisitor.cs new file mode 100644 index 000000000..3276c5b7a --- /dev/null +++ b/src/Squidex.Infrastructure/Queries/OData/ConstantWithTypeVisitor.cs @@ -0,0 +1,178 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Linq; +using Microsoft.OData; +using Microsoft.OData.Edm; +using Microsoft.OData.UriParser; +using NodaTime; +using NodaTime.Text; + +namespace Squidex.Infrastructure.Queries.OData +{ + public sealed class ConstantWithTypeVisitor : QueryNodeVisitor + { + private static readonly IEdmPrimitiveType BooleanType = EdmCoreModel.Instance.GetPrimitiveType(EdmPrimitiveTypeKind.Boolean); + private static readonly IEdmPrimitiveType DateTimeType = EdmCoreModel.Instance.GetPrimitiveType(EdmPrimitiveTypeKind.DateTimeOffset); + private static readonly IEdmPrimitiveType DoubleType = EdmCoreModel.Instance.GetPrimitiveType(EdmPrimitiveTypeKind.Double); + private static readonly IEdmPrimitiveType GuidType = EdmCoreModel.Instance.GetPrimitiveType(EdmPrimitiveTypeKind.Guid); + private static readonly IEdmPrimitiveType Int32Type = EdmCoreModel.Instance.GetPrimitiveType(EdmPrimitiveTypeKind.Int32); + private static readonly IEdmPrimitiveType Int64Type = EdmCoreModel.Instance.GetPrimitiveType(EdmPrimitiveTypeKind.Int64); + private static readonly IEdmPrimitiveType SingleType = EdmCoreModel.Instance.GetPrimitiveType(EdmPrimitiveTypeKind.Single); + private static readonly IEdmPrimitiveType StringType = EdmCoreModel.Instance.GetPrimitiveType(EdmPrimitiveTypeKind.String); + + private static readonly ConstantWithTypeVisitor Instance = new ConstantWithTypeVisitor(); + + private ConstantWithTypeVisitor() + { + } + + public static FilterValue Visit(QueryNode node) + { + return node.Accept(Instance); + } + + public override FilterValue Visit(ConvertNode nodeIn) + { + if (nodeIn.TypeReference.Definition == BooleanType) + { + var value = ConstantVisitor.Visit(nodeIn.Source); + + return new FilterValue(bool.Parse(value.ToString())); + } + + if (nodeIn.TypeReference.Definition == GuidType) + { + var value = ConstantVisitor.Visit(nodeIn.Source); + + return new FilterValue(Guid.Parse(value.ToString())); + } + + if (nodeIn.TypeReference.Definition == DateTimeType) + { + var value = ConstantVisitor.Visit(nodeIn.Source); + + return new FilterValue(ParseInstant(value)); + } + + if (ConstantVisitor.Visit(nodeIn.Source) == null) + { + return FilterValue.Null; + } + + throw new NotSupportedException(); + } + + public override FilterValue Visit(CollectionConstantNode nodeIn) + { + if (nodeIn.ItemType.Definition == DateTimeType) + { + return new FilterValue(nodeIn.Collection.Select(x => ParseInstant(x.Value)).ToList()); + } + + if (nodeIn.ItemType.Definition == GuidType) + { + return new FilterValue(nodeIn.Collection.Select(x => (Guid)x.Value).ToList()); + } + + if (nodeIn.ItemType.Definition == BooleanType) + { + return new FilterValue(nodeIn.Collection.Select(x => (bool)x.Value).ToList()); + } + + if (nodeIn.ItemType.Definition == SingleType) + { + return new FilterValue(nodeIn.Collection.Select(x => (float)x.Value).ToList()); + } + + if (nodeIn.ItemType.Definition == DoubleType) + { + return new FilterValue(nodeIn.Collection.Select(x => (double)x.Value).ToList()); + } + + if (nodeIn.ItemType.Definition == Int32Type) + { + return new FilterValue(nodeIn.Collection.Select(x => (int)x.Value).ToList()); + } + + if (nodeIn.ItemType.Definition == Int64Type) + { + return new FilterValue(nodeIn.Collection.Select(x => (long)x.Value).ToList()); + } + + if (nodeIn.ItemType.Definition == StringType) + { + return new FilterValue(nodeIn.Collection.Select(x => (string)x.Value).ToList()); + } + + throw new NotSupportedException(); + } + + public override FilterValue Visit(ConstantNode nodeIn) + { + if (nodeIn.TypeReference.Definition == BooleanType) + { + return new FilterValue((bool)nodeIn.Value); + } + + if (nodeIn.TypeReference.Definition == SingleType) + { + return new FilterValue((float)nodeIn.Value); + } + + if (nodeIn.TypeReference.Definition == DoubleType) + { + return new FilterValue((double)nodeIn.Value); + } + + if (nodeIn.TypeReference.Definition == Int32Type) + { + return new FilterValue((int)nodeIn.Value); + } + + if (nodeIn.TypeReference.Definition == Int64Type) + { + return new FilterValue((long)nodeIn.Value); + } + + if (nodeIn.TypeReference.Definition == StringType) + { + return new FilterValue((string)nodeIn.Value); + } + + throw new NotSupportedException(); + } + + private Instant ParseInstant(object value) + { + if (value is DateTimeOffset dateTimeOffset) + { + return Instant.FromDateTimeOffset(dateTimeOffset.Add(dateTimeOffset.Offset)); + } + + if (value is DateTime dateTime) + { + return Instant.FromDateTimeUtc(DateTime.SpecifyKind(dateTime, DateTimeKind.Utc)); + } + + if (value is Date date) + { + return Instant.FromUtc(date.Year, date.Month, date.Day, 0, 0); + } + + var parseResult = InstantPattern.General.Parse(value.ToString()); + + if (!parseResult.Success) + { + throw new ODataException("Datetime is not in a valid format. Use ISO 8601"); + } + + return parseResult.Value; + } + } +} diff --git a/src/Squidex.Infrastructure/Queries/OData/EdmModelExtensions.cs b/src/Squidex.Infrastructure/Queries/OData/EdmModelExtensions.cs new file mode 100644 index 000000000..53954b64f --- /dev/null +++ b/src/Squidex.Infrastructure/Queries/OData/EdmModelExtensions.cs @@ -0,0 +1,53 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Linq; +using Microsoft.OData.Edm; +using Microsoft.OData.UriParser; + +namespace Squidex.Infrastructure.Queries.OData +{ + public static class EdmModelExtensions + { + public static ODataUriParser ParseQuery(this IEdmModel model, string query) + { + if (!model.EntityContainer.EntitySets().Any()) + { + return null; + } + + query = query ?? string.Empty; + + var path = model.EntityContainer.EntitySets().First().Path.Path.Split('.').Last(); + + if (query.StartsWith("?", StringComparison.Ordinal)) + { + query = query.Substring(1); + } + + var parser = new ODataUriParser(model, new Uri($"{path}?{query}", UriKind.Relative)); + + return parser; + } + + public static Query ToQuery(this ODataUriParser parser) + { + var query = new Query(); + + if (parser != null) + { + parser.ParseTake(query); + parser.ParseSkip(query); + parser.ParseFilter(query); + parser.ParseSort(query); + } + + return query; + } + } +} diff --git a/src/Squidex.Infrastructure/Queries/OData/FilterBuilder.cs b/src/Squidex.Infrastructure/Queries/OData/FilterBuilder.cs new file mode 100644 index 000000000..6e378bb80 --- /dev/null +++ b/src/Squidex.Infrastructure/Queries/OData/FilterBuilder.cs @@ -0,0 +1,48 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.OData; +using Microsoft.OData.UriParser; + +namespace Squidex.Infrastructure.Queries.OData +{ + public static class FilterBuilder + { + public static void ParseFilter(this ODataUriParser query, Query result) + { + SearchClause search; + try + { + search = query.ParseSearch(); + } + catch (ODataException ex) + { + throw new ValidationException("Query $search clause not valid.", new ValidationError(ex.Message)); + } + + if (search != null) + { + result.FullText = SearchTermVisitor.Visit(search.Expression).ToString(); + } + + FilterClause filter; + try + { + filter = query.ParseFilter(); + } + catch (ODataException ex) + { + throw new ValidationException("Query $filter clause not valid.", new ValidationError(ex.Message)); + } + + if (filter != null) + { + result.Filter = FilterVisitor.Visit(filter.Expression); + } + } + } +} diff --git a/src/Squidex.Infrastructure/Queries/OData/FilterVisitor.cs b/src/Squidex.Infrastructure/Queries/OData/FilterVisitor.cs new file mode 100644 index 000000000..dfd603535 --- /dev/null +++ b/src/Squidex.Infrastructure/Queries/OData/FilterVisitor.cs @@ -0,0 +1,155 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Linq; +using Microsoft.OData.UriParser; + +namespace Squidex.Infrastructure.Queries.OData +{ + public sealed class FilterVisitor : QueryNodeVisitor + { + private static readonly FilterVisitor Instance = new FilterVisitor(); + + private FilterVisitor() + { + } + + public static FilterNode Visit(QueryNode node) + { + return node.Accept(Instance); + } + + public override FilterNode Visit(ConvertNode nodeIn) + { + return nodeIn.Source.Accept(this); + } + + public override FilterNode Visit(UnaryOperatorNode nodeIn) + { + if (nodeIn.OperatorKind == UnaryOperatorKind.Not) + { + return new FilterNegate(nodeIn.Operand.Accept(this)); + } + + throw new NotSupportedException(); + } + + public override FilterNode Visit(InNode nodeIn) + { + var value = ConstantWithTypeVisitor.Visit(nodeIn.Right); + + return new FilterComparison(PropertyPathVisitor.Visit(nodeIn.Left), FilterOperator.In, value); + } + + public override FilterNode Visit(SingleValueFunctionCallNode nodeIn) + { + var fieldNode = nodeIn.Parameters.ElementAt(0); + var valueNode = nodeIn.Parameters.ElementAt(1); + + if (string.Equals(nodeIn.Name, "endswith", StringComparison.OrdinalIgnoreCase)) + { + var value = ConstantWithTypeVisitor.Visit(valueNode); + + return new FilterComparison(PropertyPathVisitor.Visit(fieldNode), FilterOperator.EndsWith, value); + } + + if (string.Equals(nodeIn.Name, "startswith", StringComparison.OrdinalIgnoreCase)) + { + var value = ConstantWithTypeVisitor.Visit(valueNode); + + return new FilterComparison(PropertyPathVisitor.Visit(fieldNode), FilterOperator.StartsWith, value); + } + + if (string.Equals(nodeIn.Name, "contains", StringComparison.OrdinalIgnoreCase)) + { + var value = ConstantWithTypeVisitor.Visit(valueNode); + + return new FilterComparison(PropertyPathVisitor.Visit(fieldNode), FilterOperator.Contains, value); + } + + throw new NotSupportedException(); + } + + public override FilterNode Visit(BinaryOperatorNode nodeIn) + { + if (nodeIn.OperatorKind == BinaryOperatorKind.And) + { + return new FilterJunction(FilterJunctionType.And, nodeIn.Left.Accept(this), nodeIn.Right.Accept(this)); + } + + if (nodeIn.OperatorKind == BinaryOperatorKind.Or) + { + return new FilterJunction(FilterJunctionType.Or, nodeIn.Left.Accept(this), nodeIn.Right.Accept(this)); + } + + if (nodeIn.Left is SingleValueFunctionCallNode functionNode) + { + var regexFilter = Visit(functionNode); + + var value = ConstantWithTypeVisitor.Visit(nodeIn.Right); + + if (value.ValueType == FilterValueType.Boolean && value.Value is bool booleanRight) + { + if ((nodeIn.OperatorKind == BinaryOperatorKind.Equal && !booleanRight) || + (nodeIn.OperatorKind == BinaryOperatorKind.NotEqual && booleanRight)) + { + regexFilter = new FilterNegate(regexFilter); + } + + return regexFilter; + } + } + else + { + if (nodeIn.OperatorKind == BinaryOperatorKind.NotEqual) + { + var value = ConstantWithTypeVisitor.Visit(nodeIn.Right); + + return new FilterComparison(PropertyPathVisitor.Visit(nodeIn.Left), FilterOperator.NotEquals, value); + } + + if (nodeIn.OperatorKind == BinaryOperatorKind.Equal) + { + var value = ConstantWithTypeVisitor.Visit(nodeIn.Right); + + return new FilterComparison(PropertyPathVisitor.Visit(nodeIn.Left), FilterOperator.Equals, value); + } + + if (nodeIn.OperatorKind == BinaryOperatorKind.LessThan) + { + var value = ConstantWithTypeVisitor.Visit(nodeIn.Right); + + return new FilterComparison(PropertyPathVisitor.Visit(nodeIn.Left), FilterOperator.LessThan, value); + } + + if (nodeIn.OperatorKind == BinaryOperatorKind.LessThanOrEqual) + { + var value = ConstantWithTypeVisitor.Visit(nodeIn.Right); + + return new FilterComparison(PropertyPathVisitor.Visit(nodeIn.Left), FilterOperator.LessThanOrEqual, value); + } + + if (nodeIn.OperatorKind == BinaryOperatorKind.GreaterThan) + { + var value = ConstantWithTypeVisitor.Visit(nodeIn.Right); + + return new FilterComparison(PropertyPathVisitor.Visit(nodeIn.Left), FilterOperator.GreaterThan, value); + } + + if (nodeIn.OperatorKind == BinaryOperatorKind.GreaterThanOrEqual) + { + var value = ConstantWithTypeVisitor.Visit(nodeIn.Right); + + return new FilterComparison(PropertyPathVisitor.Visit(nodeIn.Left), FilterOperator.GreaterThanOrEqual, value); + } + } + + throw new NotSupportedException(); + } + } +} diff --git a/src/Squidex.Infrastructure/Queries/OData/LimitExtensions.cs b/src/Squidex.Infrastructure/Queries/OData/LimitExtensions.cs new file mode 100644 index 000000000..68532b5b6 --- /dev/null +++ b/src/Squidex.Infrastructure/Queries/OData/LimitExtensions.cs @@ -0,0 +1,34 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.OData.UriParser; + +namespace Squidex.Infrastructure.Queries.OData +{ + public static class LimitExtensions + { + public static void ParseTake(this ODataUriParser query, Query result) + { + var top = query.ParseTop(); + + if (top.HasValue) + { + result.Take = top.Value; + } + } + + public static void ParseSkip(this ODataUriParser query, Query result) + { + var skip = query.ParseSkip(); + + if (skip.HasValue) + { + result.Skip = skip.Value; + } + } + } +} diff --git a/src/Squidex.Infrastructure/Queries/OData/PropertyPathVisitor.cs b/src/Squidex.Infrastructure/Queries/OData/PropertyPathVisitor.cs new file mode 100644 index 000000000..6dd5e5a3e --- /dev/null +++ b/src/Squidex.Infrastructure/Queries/OData/PropertyPathVisitor.cs @@ -0,0 +1,61 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Immutable; +using Microsoft.OData.Edm; +using Microsoft.OData.UriParser; + +namespace Squidex.Infrastructure.Queries.OData +{ + public sealed class PropertyPathVisitor : QueryNodeVisitor> + { + private static readonly PropertyPathVisitor Instance = new PropertyPathVisitor(); + + private PropertyPathVisitor() + { + } + + public static ImmutableList Visit(QueryNode node) + { + return node.Accept(Instance); + } + + public override ImmutableList Visit(ConvertNode nodeIn) + { + return nodeIn.Source.Accept(this); + } + + public override ImmutableList Visit(SingleComplexNode nodeIn) + { + if (nodeIn.Source is SingleComplexNode) + { + return nodeIn.Source.Accept(this).Add(UnescapeEdmField(nodeIn.Property)); + } + else + { + return ImmutableList.Create(UnescapeEdmField(nodeIn.Property)); + } + } + + public override ImmutableList Visit(SingleValuePropertyAccessNode nodeIn) + { + if (nodeIn.Source is SingleComplexNode) + { + return nodeIn.Source.Accept(this).Add(UnescapeEdmField(nodeIn.Property)); + } + else + { + return ImmutableList.Create(UnescapeEdmField(nodeIn.Property)); + } + } + + private static string UnescapeEdmField(IEdmProperty property) + { + return property.Name; + } + } +} diff --git a/src/Squidex.Infrastructure/Queries/OData/SearchTermVisitor.cs b/src/Squidex.Infrastructure/Queries/OData/SearchTermVisitor.cs new file mode 100644 index 000000000..846e973a9 --- /dev/null +++ b/src/Squidex.Infrastructure/Queries/OData/SearchTermVisitor.cs @@ -0,0 +1,41 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Microsoft.OData.UriParser; + +namespace Squidex.Infrastructure.Queries.OData +{ + public class SearchTermVisitor : QueryNodeVisitor + { + private static readonly SearchTermVisitor Instance = new SearchTermVisitor(); + + private SearchTermVisitor() + { + } + + public static object Visit(QueryNode node) + { + return node.Accept(Instance); + } + + public override string Visit(BinaryOperatorNode nodeIn) + { + if (nodeIn.OperatorKind == BinaryOperatorKind.And) + { + return nodeIn.Left.Accept(this) + " " + nodeIn.Right.Accept(this); + } + + throw new NotSupportedException(); + } + + public override string Visit(SearchTermNode nodeIn) + { + return nodeIn.Text; + } + } +} diff --git a/src/Squidex.Infrastructure/Queries/OData/SortBuilder.cs b/src/Squidex.Infrastructure/Queries/OData/SortBuilder.cs new file mode 100644 index 000000000..e9b5f748b --- /dev/null +++ b/src/Squidex.Infrastructure/Queries/OData/SortBuilder.cs @@ -0,0 +1,43 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.OData.UriParser; + +namespace Squidex.Infrastructure.Queries.OData +{ + public static class SortBuilder + { + public static void ParseSort(this ODataUriParser query, Query result) + { + var orderBy = query.ParseOrderBy(); + + if (orderBy != null) + { + while (orderBy != null) + { + result.Sort.Add(OrderBy(orderBy)); + + orderBy = orderBy.ThenBy; + } + } + } + + public static SortNode OrderBy(OrderByClause clause) + { + var path = PropertyPathVisitor.Visit(clause.Expression); + + if (clause.Direction == OrderByDirection.Ascending) + { + return new SortNode(path, SortOrder.Ascending); + } + else + { + return new SortNode(path, SortOrder.Descending); + } + } + } +} diff --git a/src/Squidex.Infrastructure/Queries/PascalCasePathConverter.cs b/src/Squidex.Infrastructure/Queries/PascalCasePathConverter.cs new file mode 100644 index 000000000..72e12cb1b --- /dev/null +++ b/src/Squidex.Infrastructure/Queries/PascalCasePathConverter.cs @@ -0,0 +1,30 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Linq; + +namespace Squidex.Infrastructure.Queries +{ + public sealed class PascalCasePathConverter : TransformVisitor + { + private static readonly PascalCasePathConverter Instance = new PascalCasePathConverter(); + + private PascalCasePathConverter() + { + } + + public static FilterNode Transform(FilterNode node) + { + return node.Accept(Instance); + } + + public override FilterNode Visit(FilterComparison nodeIn) + { + return new FilterComparison(nodeIn.Lhs.Select(x => x.ToPascalCase()).ToList(), nodeIn.Operator, nodeIn.Rhs); + } + } +} diff --git a/src/Squidex.Infrastructure/Queries/Query.cs b/src/Squidex.Infrastructure/Queries/Query.cs new file mode 100644 index 000000000..4281854d5 --- /dev/null +++ b/src/Squidex.Infrastructure/Queries/Query.cs @@ -0,0 +1,56 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; + +namespace Squidex.Infrastructure.Queries +{ + public sealed class Query + { + public FilterNode Filter { get; set; } + + public string FullText { get; set; } + + public long Skip { get; set; } + + public long Take { get; set; } = long.MaxValue; + + public List Sort { get; set; } = new List(); + + public override string ToString() + { + var parts = new List(); + + if (Filter != null) + { + parts.Add($"Filter: {Filter}"); + } + + if (FullText != null) + { + parts.Add($"FullText: '{FullText.Replace("'", "\'")}'"); + } + + if (Skip > 0) + { + parts.Add($"Skip: {Skip}"); + } + + if (Take < long.MaxValue) + { + parts.Add($"Take: {Take}"); + } + + if (Sort.Count > 0) + { + parts.Add($"Sort: {string.Join(", ", Sort)}"); + } + + return string.Join("; ", parts); + } + } +} diff --git a/src/Squidex.Infrastructure/Queries/SortBuilder.cs b/src/Squidex.Infrastructure/Queries/SortBuilder.cs new file mode 100644 index 000000000..02c1aca16 --- /dev/null +++ b/src/Squidex.Infrastructure/Queries/SortBuilder.cs @@ -0,0 +1,24 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Linq; + +namespace Squidex.Infrastructure.Queries +{ + public static class SortBuilder + { + public static SortNode Ascending(string path) + { + return new SortNode(path.Split('.', '/').ToList(), SortOrder.Ascending); + } + + public static SortNode Descending(string path) + { + return new SortNode(path.Split('.', '/').ToList(), SortOrder.Descending); + } + } +} diff --git a/src/Squidex.Infrastructure/Queries/SortNode.cs b/src/Squidex.Infrastructure/Queries/SortNode.cs new file mode 100644 index 000000000..030b0bafe --- /dev/null +++ b/src/Squidex.Infrastructure/Queries/SortNode.cs @@ -0,0 +1,36 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; + +namespace Squidex.Infrastructure.Queries +{ + public sealed class SortNode + { + public IReadOnlyList Path { get; } + + public SortOrder SortOrder { get; set; } + + public SortNode(IReadOnlyList path, SortOrder sortOrder) + { + Guard.NotNull(path, nameof(path)); + Guard.NotEmpty(path, nameof(path)); + Guard.Enum(sortOrder, nameof(sortOrder)); + + Path = path; + + SortOrder = sortOrder; + } + + public override string ToString() + { + var path = string.Join(".", Path); + + return $"{path} {SortOrder}"; + } + } +} \ No newline at end of file diff --git a/src/Squidex.Infrastructure/Queries/SortOrder.cs b/src/Squidex.Infrastructure/Queries/SortOrder.cs new file mode 100644 index 000000000..cfec5b783 --- /dev/null +++ b/src/Squidex.Infrastructure/Queries/SortOrder.cs @@ -0,0 +1,15 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Infrastructure.Queries +{ + public enum SortOrder + { + Ascending, + Descending + } +} \ No newline at end of file diff --git a/src/Squidex.Infrastructure/Queries/TransformVisitor.cs b/src/Squidex.Infrastructure/Queries/TransformVisitor.cs new file mode 100644 index 000000000..38f60a4ff --- /dev/null +++ b/src/Squidex.Infrastructure/Queries/TransformVisitor.cs @@ -0,0 +1,29 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Linq; + +namespace Squidex.Infrastructure.Queries +{ + public abstract class TransformVisitor : FilterNodeVisitor + { + public override FilterNode Visit(FilterComparison nodeIn) + { + return nodeIn; + } + + public override FilterNode Visit(FilterJunction nodeIn) + { + return new FilterJunction(nodeIn.JunctionType, nodeIn.Operands.Select(x => x.Accept(this)).ToList()); + } + + public override FilterNode Visit(FilterNegate nodeIn) + { + return new FilterNegate(nodeIn.Operand.Accept(this)); + } + } +} diff --git a/src/Squidex.Infrastructure/RefTokenType.cs b/src/Squidex.Infrastructure/RefTokenType.cs new file mode 100644 index 000000000..c8ee8944e --- /dev/null +++ b/src/Squidex.Infrastructure/RefTokenType.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Infrastructure +{ + public static class RefTokenType + { + public const string Subject = "subject"; + + public const string Client = "client"; + } +} diff --git a/src/Squidex.Infrastructure/ResultList.cs b/src/Squidex.Infrastructure/ResultList.cs index 07d80e75b..957ecc48b 100644 --- a/src/Squidex.Infrastructure/ResultList.cs +++ b/src/Squidex.Infrastructure/ResultList.cs @@ -22,7 +22,12 @@ namespace Squidex.Infrastructure } } - public static IResultList Create(IEnumerable items, long total) + public static IResultList Create(long total, IEnumerable items) + { + return new Impl(items, total); + } + + public static IResultList Create(long total, params T[] items) { return new Impl(items, total); } diff --git a/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj b/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj index ba723caf1..e39741428 100644 --- a/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj +++ b/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj @@ -8,20 +8,28 @@ True - - - - - - - - + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + + - - + + - - + + ..\..\Squidex.ruleset diff --git a/src/Squidex.Infrastructure/States/CollectionNameAttribute.cs b/src/Squidex.Infrastructure/States/CollectionNameAttribute.cs new file mode 100644 index 000000000..647420a4b --- /dev/null +++ b/src/Squidex.Infrastructure/States/CollectionNameAttribute.cs @@ -0,0 +1,22 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; + +namespace Squidex.Infrastructure.States +{ + [AttributeUsage(AttributeTargets.Class)] + public sealed class CollectionNameAttribute : Attribute + { + public string Name { get; } + + public CollectionNameAttribute(string name) + { + Name = name; + } + } +} diff --git a/src/Squidex.Infrastructure/States/DefaultStreamNameResolver.cs b/src/Squidex.Infrastructure/States/DefaultStreamNameResolver.cs index 500a44dbf..582011a20 100644 --- a/src/Squidex.Infrastructure/States/DefaultStreamNameResolver.cs +++ b/src/Squidex.Infrastructure/States/DefaultStreamNameResolver.cs @@ -11,10 +11,13 @@ namespace Squidex.Infrastructure.States { public sealed class DefaultStreamNameResolver : IStreamNameResolver { - private static readonly string[] Suffixes = { "Grain", "DomainObject" }; + private static readonly string[] Suffixes = { "Grain", "DomainObject", "State" }; public string GetStreamName(Type aggregateType, string id) { + Guard.NotNullOrEmpty(id, nameof(id)); + Guard.NotNull(aggregateType, nameof(aggregateType)); + var typeName = char.ToLower(aggregateType.Name[0]) + aggregateType.Name.Substring(1); foreach (var suffix in Suffixes) @@ -29,5 +32,25 @@ namespace Squidex.Infrastructure.States return $"{typeName}-{id}"; } + + public string WithNewId(string streamName, Func idGenerator) + { + Guard.NotNullOrEmpty(streamName, nameof(streamName)); + Guard.NotNull(idGenerator, nameof(idGenerator)); + + var positionOfDash = streamName.IndexOf('-'); + + if (positionOfDash >= 0) + { + var newId = idGenerator(streamName.Substring(positionOfDash + 1)); + + if (!string.IsNullOrWhiteSpace(newId)) + { + streamName = $"{streamName.Substring(0, positionOfDash)}-{newId}"; + } + } + + return streamName; + } } } diff --git a/src/Squidex.Infrastructure/States/IPersistence.cs b/src/Squidex.Infrastructure/States/IPersistence.cs index c71ff14a2..523a9dd0b 100644 --- a/src/Squidex.Infrastructure/States/IPersistence.cs +++ b/src/Squidex.Infrastructure/States/IPersistence.cs @@ -5,24 +5,9 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Collections.Generic; -using System.Threading.Tasks; -using Squidex.Infrastructure.EventSourcing; - namespace Squidex.Infrastructure.States { public interface IPersistence : IPersistence { } - - public interface IPersistence - { - long Version { get; } - - Task WriteEventsAsync(IEnumerable> @events); - - Task WriteSnapshotAsync(TState state); - - Task ReadAsync(long expectedVersion = EtagVersion.Any); - } } diff --git a/src/Squidex.Infrastructure/States/IPersistence{TState}.cs b/src/Squidex.Infrastructure/States/IPersistence{TState}.cs new file mode 100644 index 000000000..a804dece1 --- /dev/null +++ b/src/Squidex.Infrastructure/States/IPersistence{TState}.cs @@ -0,0 +1,26 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Threading.Tasks; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Infrastructure.States +{ + public interface IPersistence + { + long Version { get; } + + Task DeleteAsync(); + + Task WriteEventsAsync(IEnumerable> events); + + Task WriteSnapshotAsync(TState state); + + Task ReadAsync(long expectedVersion = EtagVersion.Any); + } +} diff --git a/src/Squidex.Infrastructure/States/ISnapshotStore.cs b/src/Squidex.Infrastructure/States/ISnapshotStore.cs index d20fc7437..38646e64f 100644 --- a/src/Squidex.Infrastructure/States/ISnapshotStore.cs +++ b/src/Squidex.Infrastructure/States/ISnapshotStore.cs @@ -5,16 +5,21 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Threading.Tasks; namespace Squidex.Infrastructure.States { - public interface ISnapshotStore + public interface ISnapshotStore { Task WriteAsync(TKey key, T value, long oldVersion, long newVersion); Task<(T Value, long Version)> ReadAsync(TKey key); Task ClearAsync(); + + Task RemoveAsync(TKey key); + + Task ReadAllAsync(Func callback); } } diff --git a/src/Squidex.Infrastructure/States/IStateFactory.cs b/src/Squidex.Infrastructure/States/IStateFactory.cs deleted file mode 100644 index cc10879ec..000000000 --- a/src/Squidex.Infrastructure/States/IStateFactory.cs +++ /dev/null @@ -1,31 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Threading.Tasks; - -namespace Squidex.Infrastructure.States -{ - public interface IStateFactory - { - Task GetSingleAsync(string key) where T : IStatefulObject; - - Task GetSingleAsync(Guid key) where T : IStatefulObject; - - Task GetSingleAsync(TKey key) where T : IStatefulObject; - - Task CreateAsync(string key) where T : IStatefulObject; - - Task CreateAsync(Guid key) where T : IStatefulObject; - - Task CreateAsync(TKey key) where T : IStatefulObject; - - void Remove(TKey key) where T : IStatefulObject; - - void Synchronize(TKey key) where T : IStatefulObject; - } -} diff --git a/src/Squidex.Infrastructure/States/IStatefulObject.cs b/src/Squidex.Infrastructure/States/IStatefulObject.cs deleted file mode 100644 index 45769b2ce..000000000 --- a/src/Squidex.Infrastructure/States/IStatefulObject.cs +++ /dev/null @@ -1,16 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Threading.Tasks; - -namespace Squidex.Infrastructure.States -{ - public interface IStatefulObject - { - Task ActivateAsync(TKey key); - } -} diff --git a/src/Squidex.Infrastructure/States/IStore.cs b/src/Squidex.Infrastructure/States/IStore.cs index 1ba437fde..28c44fd78 100644 --- a/src/Squidex.Infrastructure/States/IStore.cs +++ b/src/Squidex.Infrastructure/States/IStore.cs @@ -11,12 +11,14 @@ using Squidex.Infrastructure.EventSourcing; namespace Squidex.Infrastructure.States { - public interface IStore + public interface IStore { IPersistence WithEventSourcing(Type owner, TKey key, Func, Task> applyEvent); IPersistence WithSnapshots(Type owner, TKey key, Func applySnapshot); IPersistence WithSnapshotsAndEventSourcing(Type owner, TKey key, Func applySnapshot, Func, Task> applyEvent); + + ISnapshotStore GetSnapshotStore(); } } diff --git a/src/Squidex.Infrastructure/States/IStreamNameResolver.cs b/src/Squidex.Infrastructure/States/IStreamNameResolver.cs index a8d13034c..02b15f2fb 100644 --- a/src/Squidex.Infrastructure/States/IStreamNameResolver.cs +++ b/src/Squidex.Infrastructure/States/IStreamNameResolver.cs @@ -12,5 +12,7 @@ namespace Squidex.Infrastructure.States public interface IStreamNameResolver { string GetStreamName(Type aggregateType, string id); + + string WithNewId(string streamName, Func idGenerator); } } diff --git a/src/Squidex.Infrastructure/States/InconsistentStateException.cs b/src/Squidex.Infrastructure/States/InconsistentStateException.cs index 843c65583..d9091b9dd 100644 --- a/src/Squidex.Infrastructure/States/InconsistentStateException.cs +++ b/src/Squidex.Infrastructure/States/InconsistentStateException.cs @@ -37,6 +37,17 @@ namespace Squidex.Infrastructure.States protected InconsistentStateException(SerializationInfo info, StreamingContext context) : base(info, context) { + currentVersion = info.GetInt64(nameof(currentVersion)); + + expectedVersion = info.GetInt64(nameof(expectedVersion)); + } + + public override void GetObjectData(SerializationInfo info, StreamingContext context) + { + info.AddValue(nameof(currentVersion), currentVersion); + info.AddValue(nameof(expectedVersion), expectedVersion); + + base.GetObjectData(info, context); } private static string FormatMessage(long currentVersion, long expectedVersion) diff --git a/src/Squidex.Infrastructure/States/InvalidateMessage.cs b/src/Squidex.Infrastructure/States/InvalidateMessage.cs deleted file mode 100644 index b55f77145..000000000 --- a/src/Squidex.Infrastructure/States/InvalidateMessage.cs +++ /dev/null @@ -1,14 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -namespace Squidex.Infrastructure.States -{ - public sealed class InvalidateMessage - { - public string Key { get; set; } - } -} \ No newline at end of file diff --git a/src/Squidex.Infrastructure/States/Persistence{TSnapshot,TKey}.cs b/src/Squidex.Infrastructure/States/Persistence{TSnapshot,TKey}.cs index ea8b50d1e..d07e2d7a4 100644 --- a/src/Squidex.Infrastructure/States/Persistence{TSnapshot,TKey}.cs +++ b/src/Squidex.Infrastructure/States/Persistence{TSnapshot,TKey}.cs @@ -147,7 +147,7 @@ namespace Squidex.Infrastructure.States public async Task WriteEventsAsync(IEnumerable> events) { - Guard.NotNull(events, nameof(@events)); + Guard.NotNull(events, nameof(events)); var eventArray = events.ToArray(); @@ -162,7 +162,7 @@ namespace Squidex.Infrastructure.States try { - await eventStore.AppendAsync(commitId, GetStreamName(), expectedVersion, eventData); + await eventStore.AppendAsync(commitId, eventStream, expectedVersion, eventData); } catch (WrongEventVersionException ex) { @@ -175,9 +175,22 @@ namespace Squidex.Infrastructure.States UpdateVersion(); } + public async Task DeleteAsync() + { + if (UseEventSourcing()) + { + await eventStore.DeleteStreamAsync(GetStreamName()); + } + + if (UseSnapshots()) + { + await snapshotStore.RemoveAsync(ownerKey); + } + } + private EventData[] GetEventData(Envelope[] events, Guid commitId) { - return @events.Select(x => eventDataFormatter.ToEventData(x, commitId, true)).ToArray(); + return events.Select(x => eventDataFormatter.ToEventData(x, commitId, true)).ToArray(); } private string GetStreamName() diff --git a/src/Squidex.Infrastructure/States/StateFactory.cs b/src/Squidex.Infrastructure/States/StateFactory.cs deleted file mode 100644 index 6e196beba..000000000 --- a/src/Squidex.Infrastructure/States/StateFactory.cs +++ /dev/null @@ -1,140 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Threading.Tasks; -using Microsoft.Extensions.Caching.Memory; - -#pragma warning disable RECS0096 // Type parameter is never used - -namespace Squidex.Infrastructure.States -{ - public sealed class StateFactory : DisposableObjectBase, IInitializable, IStateFactory - { - private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10); - private readonly IPubSub pubSub; - private readonly IMemoryCache statesCache; - private readonly IServiceProvider services; - private readonly object lockObject = new object(); - private IDisposable pubSubSubscription; - - public sealed class ObjectHolder where T : IStatefulObject - { - private readonly Task activationTask; - private readonly T obj; - - public ObjectHolder(T obj, TKey key) - { - this.obj = obj; - - activationTask = obj.ActivateAsync(key); - } - - public async Task ActivateAsync() - { - await activationTask; - - return obj; - } - } - - public StateFactory(IPubSub pubSub, IMemoryCache statesCache, IServiceProvider services) - { - Guard.NotNull(pubSub, nameof(pubSub)); - Guard.NotNull(services, nameof(services)); - Guard.NotNull(statesCache, nameof(statesCache)); - - this.pubSub = pubSub; - this.services = services; - this.statesCache = statesCache; - } - - public void Initialize() - { - pubSubSubscription = pubSub.Subscribe(m => - { - lock (lockObject) - { - statesCache.Remove(m.Key); - } - }); - } - - public Task CreateAsync(string key) where T : IStatefulObject - { - return CreateAsync(key); - } - - public Task CreateAsync(Guid key) where T : IStatefulObject - { - return CreateAsync(key); - } - - public async Task CreateAsync(TKey key) where T : IStatefulObject - { - Guard.NotNull(key, nameof(key)); - - var state = (T)services.GetService(typeof(T)); - - await state.ActivateAsync(key); - - return state; - } - - public Task GetSingleAsync(string key) where T : IStatefulObject - { - return GetSingleAsync(key); - } - - public Task GetSingleAsync(Guid key) where T : IStatefulObject - { - return GetSingleAsync(key); - } - - public Task GetSingleAsync(TKey key) where T : IStatefulObject - { - Guard.NotNull(key, nameof(key)); - - lock (lockObject) - { - if (statesCache.TryGetValue>(key, out var stateObj)) - { - return stateObj.ActivateAsync(); - } - - var state = (T)services.GetService(typeof(T)); - - stateObj = new ObjectHolder(state, key); - - statesCache.CreateEntry(key) - .SetValue(stateObj) - .SetAbsoluteExpiration(CacheDuration) - .Dispose(); - - return stateObj.ActivateAsync(); - } - } - - public void Remove(TKey key) where T : IStatefulObject - { - statesCache.Remove(key); - } - - public void Synchronize(TKey key) where T : IStatefulObject - { - pubSub.Publish(new InvalidateMessage { Key = key.ToString() }, false); - } - - protected override void DisposeObject(bool disposing) - { - if (disposing && pubSubSubscription != null) - { - pubSubSubscription.Dispose(); - } - } - } -} diff --git a/src/Squidex.Infrastructure/States/Store.cs b/src/Squidex.Infrastructure/States/Store.cs index a3d6b1bc6..3bbacc36d 100644 --- a/src/Squidex.Infrastructure/States/Store.cs +++ b/src/Squidex.Infrastructure/States/Store.cs @@ -32,30 +32,45 @@ namespace Squidex.Infrastructure.States public IPersistence WithSnapshots(Type owner, TKey key, Func applySnapshot) { - return CreatePersistence(owner, key, PersistenceMode.Snapshots, applySnapshot, null); + return CreatePersistence(owner, key, PersistenceMode.Snapshots, applySnapshot, null); } public IPersistence WithSnapshotsAndEventSourcing(Type owner, TKey key, Func applySnapshot, Func, Task> applyEvent) { - return CreatePersistence(owner, key, PersistenceMode.SnapshotsAndEventSourcing, applySnapshot, applyEvent); + return CreatePersistence(owner, key, PersistenceMode.SnapshotsAndEventSourcing, applySnapshot, applyEvent); } public IPersistence WithEventSourcing(Type owner, TKey key, Func, Task> applyEvent) { - Guard.NotDefault(key, nameof(key)); + Guard.NotNull(key, nameof(key)); - var snapshotStore = (ISnapshotStore)services.GetService(typeof(ISnapshotStore)); + var snapshotStore = GetSnapshotStore(); return new Persistence(key, owner, eventStore, eventDataFormatter, snapshotStore, streamNameResolver, applyEvent); } private IPersistence CreatePersistence(Type owner, TKey key, PersistenceMode mode, Func applySnapshot, Func, Task> applyEvent) { - Guard.NotDefault(key, nameof(key)); + Guard.NotNull(key, nameof(key)); - var snapshotStore = (ISnapshotStore)services.GetService(typeof(ISnapshotStore)); + var snapshotStore = GetSnapshotStore(); return new Persistence(key, owner, eventStore, eventDataFormatter, snapshotStore, streamNameResolver, mode, applySnapshot, applyEvent); } + + public Task ClearSnapshotsAsync() + { + return GetSnapshotStore().ClearAsync(); + } + + public Task RemoveSnapshotAsync(TKey key) + { + return GetSnapshotStore().RemoveAsync(key); + } + + public ISnapshotStore GetSnapshotStore() + { + return (ISnapshotStore)services.GetService(typeof(ISnapshotStore)); + } } } diff --git a/src/Squidex.Infrastructure/States/StoreExtensions.cs b/src/Squidex.Infrastructure/States/StoreExtensions.cs index 3cee24593..17b9bf6f8 100644 --- a/src/Squidex.Infrastructure/States/StoreExtensions.cs +++ b/src/Squidex.Infrastructure/States/StoreExtensions.cs @@ -21,12 +21,12 @@ namespace Squidex.Infrastructure.States public static IPersistence WithSnapshots(this IStore store, TKey key, Func applySnapshot) { - return store.WithSnapshots(typeof(TOwner), key, applySnapshot); + return store.WithSnapshots(typeof(TOwner), key, applySnapshot); } public static IPersistence WithSnapshotsAndEventSourcing(this IStore store, TKey key, Func applySnapshot, Func, Task> applyEvent) { - return store.WithSnapshotsAndEventSourcing(typeof(TOwner), key, applySnapshot, applyEvent); + return store.WithSnapshotsAndEventSourcing(typeof(TOwner), key, applySnapshot, applyEvent); } public static IPersistence WithEventSourcing(this IStore store, Type owner, TKey key, Action> applyEvent) @@ -36,12 +36,12 @@ namespace Squidex.Infrastructure.States public static IPersistence WithSnapshots(this IStore store, Type owner, TKey key, Action applySnapshot) { - return store.WithSnapshots(owner, key, applySnapshot.ToAsync()); + return store.WithSnapshots(owner, key, applySnapshot.ToAsync()); } public static IPersistence WithSnapshotsAndEventSourcing(this IStore store, Type owner, TKey key, Action applySnapshot, Action> applyEvent) { - return store.WithSnapshotsAndEventSourcing(owner, key, applySnapshot.ToAsync(), applyEvent.ToAsync()); + return store.WithSnapshotsAndEventSourcing(owner, key, applySnapshot.ToAsync(), applyEvent.ToAsync()); } public static IPersistence WithEventSourcing(this IStore store, TKey key, Action> applyEvent) @@ -51,12 +51,29 @@ namespace Squidex.Infrastructure.States public static IPersistence WithSnapshots(this IStore store, TKey key, Action applySnapshot) { - return store.WithSnapshots(typeof(TOwner), key, applySnapshot.ToAsync()); + return store.WithSnapshots(typeof(TOwner), key, applySnapshot.ToAsync()); } public static IPersistence WithSnapshotsAndEventSourcing(this IStore store, TKey key, Action applySnapshot, Action> applyEvent) { - return store.WithSnapshotsAndEventSourcing(typeof(TOwner), key, applySnapshot.ToAsync(), applyEvent.ToAsync()); + return store.WithSnapshotsAndEventSourcing(typeof(TOwner), key, applySnapshot.ToAsync(), applyEvent.ToAsync()); + } + + public static Task ClearSnapshotsAsync(this IStore store) + { + return store.GetSnapshotStore().ClearAsync(); + } + + public static Task RemoveSnapshotAsync(this IStore store, TKey key) + { + return store.GetSnapshotStore().RemoveAsync(key); + } + + public static async Task GetSnapshotAsync(this IStore store, TKey key) + { + var result = await store.GetSnapshotStore().ReadAsync(key); + + return result.Value; } } } diff --git a/src/Squidex.Infrastructure/StringExtensions.cs b/src/Squidex.Infrastructure/StringExtensions.cs index 98cf38e9a..55349977a 100644 --- a/src/Squidex.Infrastructure/StringExtensions.cs +++ b/src/Squidex.Infrastructure/StringExtensions.cs @@ -327,7 +327,7 @@ namespace Squidex.Infrastructure { var sb = new StringBuilder(); - foreach (var part in value.Split('-', '_', ' ')) + foreach (var part in value.Split(new[] { '-', '_', ' ' }, StringSplitOptions.RemoveEmptyEntries)) { if (part.Length < 2) { @@ -343,6 +343,23 @@ namespace Squidex.Infrastructure return sb.ToString(); } + public static string ToKebabCase(this string value) + { + var sb = new StringBuilder(); + + foreach (var part in value.Split(new[] { '-', '_', ' ' }, StringSplitOptions.RemoveEmptyEntries)) + { + if (sb.Length > 0) + { + sb.Append("-"); + } + + sb.Append(part.ToLower()); + } + + return sb.ToString(); + } + public static string ToCamelCase(this string value) { value = value.ToPascalCase(); diff --git a/src/Squidex.Infrastructure/Tasks/AsyncLocalCleaner.cs b/src/Squidex.Infrastructure/Tasks/AsyncLocalCleaner.cs new file mode 100644 index 000000000..0a38e5194 --- /dev/null +++ b/src/Squidex.Infrastructure/Tasks/AsyncLocalCleaner.cs @@ -0,0 +1,29 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading; + +namespace Squidex.Infrastructure.Tasks +{ + public sealed class AsyncLocalCleaner : IDisposable + { + private readonly AsyncLocal asyncLocal; + + public AsyncLocalCleaner(AsyncLocal asyncLocal) + { + Guard.NotNull(asyncLocal, nameof(asyncLocal)); + + this.asyncLocal = asyncLocal; + } + + public void Dispose() + { + asyncLocal.Value = default(T); + } + } +} diff --git a/src/Squidex.Infrastructure/Tasks/AsyncLock.cs b/src/Squidex.Infrastructure/Tasks/AsyncLock.cs index 40a8baf09..60e11e804 100644 --- a/src/Squidex.Infrastructure/Tasks/AsyncLock.cs +++ b/src/Squidex.Infrastructure/Tasks/AsyncLock.cs @@ -24,7 +24,7 @@ namespace Squidex.Infrastructure.Tasks public Task LockAsync() { - Task wait = semaphore.WaitAsync(); + var wait = semaphore.WaitAsync(); if (wait.IsCompleted) { @@ -50,7 +50,7 @@ namespace Squidex.Infrastructure.Tasks public void Dispose() { - AsyncLock current = target; + var current = target; if (current == null) { diff --git a/src/Squidex.Infrastructure/Tasks/PartitionedActionBlock.cs b/src/Squidex.Infrastructure/Tasks/PartitionedActionBlock.cs index 16fd0c779..a649ce53f 100644 --- a/src/Squidex.Infrastructure/Tasks/PartitionedActionBlock.cs +++ b/src/Squidex.Infrastructure/Tasks/PartitionedActionBlock.cs @@ -24,7 +24,7 @@ namespace Squidex.Infrastructure.Tasks } public PartitionedActionBlock(Action action, Func partitioner) - : this (ToAsync(action), partitioner, new ExecutionDataflowBlockOptions()) + : this (action?.ToAsync(), partitioner, new ExecutionDataflowBlockOptions()) { } @@ -34,7 +34,7 @@ namespace Squidex.Infrastructure.Tasks } public PartitionedActionBlock(Action action, Func partitioner, ExecutionDataflowBlockOptions dataflowBlockOptions) - : this(ToAsync(action), partitioner, dataflowBlockOptions) + : this(action?.ToAsync(), partitioner, dataflowBlockOptions) { } @@ -94,17 +94,5 @@ namespace Squidex.Infrastructure.Tasks { distributor.Fault(exception); } - - private static Func ToAsync(Action action) - { - Guard.NotNull(action, nameof(action)); - - return x => - { - action(x); - - return TaskHelper.Done; - }; - } } } diff --git a/src/Squidex.Infrastructure/Tasks/TaskExtensions.cs b/src/Squidex.Infrastructure/Tasks/TaskExtensions.cs index 8d5f8a548..416808ba0 100644 --- a/src/Squidex.Infrastructure/Tasks/TaskExtensions.cs +++ b/src/Squidex.Infrastructure/Tasks/TaskExtensions.cs @@ -6,14 +6,30 @@ // ========================================================================== using System; +using System.Threading; using System.Threading.Tasks; namespace Squidex.Infrastructure.Tasks { public static class TaskExtensions { + private static readonly Action IgnoreTaskContinuation = t => { var ignored = t.Exception; }; + public static void Forget(this Task task) { + if (task.IsCompleted) + { + var ignored = task.Exception; + } + else + { + task.ContinueWith( + IgnoreTaskContinuation, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } } public static Func ToDefault(this Action action) diff --git a/src/Squidex.Infrastructure/UsageTracking/BackgroundUsageTracker.cs b/src/Squidex.Infrastructure/UsageTracking/BackgroundUsageTracker.cs index 7aec7db78..a02192fae 100644 --- a/src/Squidex.Infrastructure/UsageTracking/BackgroundUsageTracker.cs +++ b/src/Squidex.Infrastructure/UsageTracking/BackgroundUsageTracker.cs @@ -19,11 +19,12 @@ namespace Squidex.Infrastructure.UsageTracking { public sealed class BackgroundUsageTracker : DisposableObjectBase, IUsageTracker { + private const string FallbackCategory = "*"; private const int Intervall = 60 * 1000; private readonly IUsageStore usageStore; private readonly ISemanticLog log; private readonly CompletionTimer timer; - private ConcurrentDictionary usages = new ConcurrentDictionary(); + private ConcurrentDictionary<(string Key, string Category), Usage> usages = new ConcurrentDictionary<(string Key, string Category), Usage>(); public BackgroundUsageTracker(IUsageStore usageStore, ISemanticLog log) { @@ -58,12 +59,13 @@ namespace Squidex.Infrastructure.UsageTracking { var today = DateTime.Today; - var localUsages = Interlocked.Exchange(ref usages, new ConcurrentDictionary()); + var localUsages = Interlocked.Exchange(ref usages, new ConcurrentDictionary<(string Key, string Category), Usage>()); await Task.WhenAll(localUsages.Select(x => usageStore.TrackUsagesAsync( today, - x.Key, + x.Key.Key, + x.Key.Category, x.Value.Count, x.Value.ElapsedMs))); } @@ -75,7 +77,7 @@ namespace Squidex.Infrastructure.UsageTracking } } - public Task TrackAsync(string key, double weight, double elapsedMs) + public Task TrackAsync(string key, string category, double weight, double elapsedMs) { Guard.NotNull(key, nameof(key)); @@ -83,33 +85,64 @@ namespace Squidex.Infrastructure.UsageTracking if (weight > 0) { - usages.AddOrUpdate(key, _ => new Usage(elapsedMs, weight), (k, x) => x.Add(elapsedMs, weight)); + category = CleanCategory(category); + + usages.AddOrUpdate((key, category), _ => new Usage(elapsedMs, weight), (k, x) => x.Add(elapsedMs, weight)); } return TaskHelper.Done; } - public async Task> QueryAsync(string key, DateTime fromDate, DateTime toDate) + public async Task>> QueryAsync(string key, DateTime fromDate, DateTime toDate) { Guard.NotNull(key, nameof(key)); ThrowIfDisposed(); - var originalUsages = await usageStore.QueryAsync(key, fromDate, toDate); - var enrichedUsages = new List(); + var usagesFlat = await usageStore.QueryAsync(key, fromDate, toDate); + var usagesByCategory = usagesFlat.GroupBy(x => CleanCategory(x.Category)).ToDictionary(x => x.Key, x => x.ToList()); + + var result = new Dictionary>(); - var usagesDictionary = originalUsages.ToDictionary(x => x.Date); + IEnumerable categories = usagesByCategory.Keys; - for (var date = fromDate; date <= toDate; date = date.AddDays(1)) + if (usagesByCategory.Count == 0) { - enrichedUsages.Add(usagesDictionary.GetOrDefault(date) ?? new StoredUsage(date, 0, 0)); + var enriched = new List(); + + for (var date = fromDate; date <= toDate; date = date.AddDays(1)) + { + enriched.Add(new DateUsage(date, 0, 0)); + } + + result[FallbackCategory] = enriched; } + else + { + foreach (var category in categories) + { + var enriched = new List(); + + var usagesDictionary = usagesByCategory[category].ToDictionary(x => x.Date); + + for (var date = fromDate; date <= toDate; date = date.AddDays(1)) + { + var stored = usagesDictionary.GetOrDefault(date); + + enriched.Add(new DateUsage(date, stored?.TotalCount ?? 0, stored?.TotalElapsedMs ?? 0)); + } - return enrichedUsages; + result[category] = enriched; + } + } + + return result; } - public async Task GetMonthlyCalls(string key, DateTime date) + public async Task GetMonthlyCallsAsync(string key, DateTime date) { + Guard.NotNull(key, nameof(key)); + ThrowIfDisposed(); var dateFrom = new DateTime(date.Year, date.Month, 1); @@ -119,5 +152,10 @@ namespace Squidex.Infrastructure.UsageTracking return originalUsages.Sum(x => x.TotalCount); } + + private static string CleanCategory(string category) + { + return !string.IsNullOrWhiteSpace(category) ? category.Trim() : "*"; + } } } diff --git a/src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs b/src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs new file mode 100644 index 000000000..732a48596 --- /dev/null +++ b/src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs @@ -0,0 +1,52 @@ +// ========================================================================== +// 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 Microsoft.Extensions.Caching.Memory; + +namespace Squidex.Infrastructure.UsageTracking +{ + public sealed class CachingUsageTracker : CachingProviderBase, IUsageTracker + { + private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10); + private readonly IUsageTracker inner; + + public CachingUsageTracker(IUsageTracker inner, IMemoryCache cache) + : base(cache) + { + Guard.NotNull(inner, nameof(inner)); + + this.inner = inner; + } + + public Task>> QueryAsync(string key, DateTime fromDate, DateTime toDate) + { + return inner.QueryAsync(key, fromDate, toDate); + } + + public Task TrackAsync(string key, string category, double weight, double elapsedMs) + { + return inner.TrackAsync(key, category, weight, elapsedMs); + } + + public Task GetMonthlyCallsAsync(string key, DateTime date) + { + Guard.NotNull(key, nameof(key)); + + var cacheKey = string.Concat(key, date); + + return Cache.GetOrCreateAsync(cacheKey, entry => + { + entry.AbsoluteExpirationRelativeToNow = CacheDuration; + + return inner.GetMonthlyCallsAsync(key, date); + }); + } + } +} diff --git a/src/Squidex.Infrastructure/UsageTracking/DateUsage.cs b/src/Squidex.Infrastructure/UsageTracking/DateUsage.cs new file mode 100644 index 000000000..fd60a63e8 --- /dev/null +++ b/src/Squidex.Infrastructure/UsageTracking/DateUsage.cs @@ -0,0 +1,28 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; + +namespace Squidex.Infrastructure.UsageTracking +{ + public sealed class DateUsage + { + public DateTime Date { get; } + + public long TotalCount { get; } + + public long TotalElapsedMs { get; } + + public DateUsage(DateTime date, long totalCount, long totalElapsedMs) + { + Date = date; + + TotalCount = totalCount; + TotalElapsedMs = totalElapsedMs; + } + } +} diff --git a/src/Squidex.Infrastructure/UsageTracking/IUsageStore.cs b/src/Squidex.Infrastructure/UsageTracking/IUsageStore.cs index d53d4aa23..b5bc0871e 100644 --- a/src/Squidex.Infrastructure/UsageTracking/IUsageStore.cs +++ b/src/Squidex.Infrastructure/UsageTracking/IUsageStore.cs @@ -13,7 +13,7 @@ namespace Squidex.Infrastructure.UsageTracking { public interface IUsageStore { - Task TrackUsagesAsync(DateTime date, string key, double count, double elapsedMs); + Task TrackUsagesAsync(DateTime date, string key, string category, double count, double elapsedMs); Task> QueryAsync(string key, DateTime fromDate, DateTime toDate); } diff --git a/src/Squidex.Infrastructure/UsageTracking/IUsageTracker.cs b/src/Squidex.Infrastructure/UsageTracking/IUsageTracker.cs index 7a28e851d..f0945d1e1 100644 --- a/src/Squidex.Infrastructure/UsageTracking/IUsageTracker.cs +++ b/src/Squidex.Infrastructure/UsageTracking/IUsageTracker.cs @@ -13,10 +13,10 @@ namespace Squidex.Infrastructure.UsageTracking { public interface IUsageTracker { - Task TrackAsync(string key, double weight, double elapsedMs); + Task TrackAsync(string key, string category, double weight, double elapsedMs); - Task GetMonthlyCalls(string key, DateTime date); + Task GetMonthlyCallsAsync(string key, DateTime date); - Task> QueryAsync(string key, DateTime fromDate, DateTime toDate); + Task>> QueryAsync(string key, DateTime fromDate, DateTime toDate); } } diff --git a/src/Squidex.Infrastructure/UsageTracking/StoredUsage.cs b/src/Squidex.Infrastructure/UsageTracking/StoredUsage.cs index 93dd02786..982498c1a 100644 --- a/src/Squidex.Infrastructure/UsageTracking/StoredUsage.cs +++ b/src/Squidex.Infrastructure/UsageTracking/StoredUsage.cs @@ -11,14 +11,18 @@ namespace Squidex.Infrastructure.UsageTracking { public sealed class StoredUsage { + public string Category { get; } + public DateTime Date { get; } public long TotalCount { get; } public long TotalElapsedMs { get; } - public StoredUsage(DateTime date, long totalCount, long totalElapsedMs) + public StoredUsage(string category, DateTime date, long totalCount, long totalElapsedMs) { + Category = category; + Date = date; TotalCount = totalCount; diff --git a/src/Squidex.Infrastructure/Validate.cs b/src/Squidex.Infrastructure/Validate.cs index 04b483152..ae85f0a21 100644 --- a/src/Squidex.Infrastructure/Validate.cs +++ b/src/Squidex.Infrastructure/Validate.cs @@ -7,32 +7,53 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; namespace Squidex.Infrastructure { + public delegate void AddValidation(string message, params string[] propertyNames); + public static class Validate { - public static void It(Func message, Action> action) + public static void It(Func message, Action action) { - var errors = new List(); + List errors = null; + + var addValidation = new AddValidation((m, p) => + { + if (errors == null) + { + errors = new List(); + } + + errors.Add(new ValidationError(m, p)); + }); - action(errors.Add); + action(addValidation); - if (errors.Any()) + if (errors != null) { throw new ValidationException(message(), errors); } } - public static async Task It(Func message, Func, Task> action) + public static async Task It(Func message, Func action) { - var errors = new List(); + List errors = null; + + var addValidation = new AddValidation((m, p) => + { + if (errors == null) + { + errors = new List(); + } + + errors.Add(new ValidationError(m, p)); + }); - await action(errors.Add); + await action(addValidation); - if (errors.Any()) + if (errors != null) { throw new ValidationException(message(), errors); } diff --git a/src/Squidex.Infrastructure/ValidationError.cs b/src/Squidex.Infrastructure/ValidationError.cs index 69eca55d6..06e294aaf 100644 --- a/src/Squidex.Infrastructure/ValidationError.cs +++ b/src/Squidex.Infrastructure/ValidationError.cs @@ -5,10 +5,13 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Collections.Generic; +using System.Linq; namespace Squidex.Infrastructure { + [Serializable] public sealed class ValidationError { private static readonly string[] FallbackProperties = new string[0]; @@ -33,5 +36,22 @@ namespace Squidex.Infrastructure this.propertyNames = propertyNames ?? FallbackProperties; } + + public ValidationError WithPrefix(string prefix) + { + if (propertyNames.Length > 0) + { + return new ValidationError(Message, propertyNames.Select(x => $"{prefix}.{x}").ToArray()); + } + else + { + return new ValidationError(Message, prefix); + } + } + + public void AddTo(AddValidation e) + { + e(Message, propertyNames); + } } } diff --git a/src/Squidex.Infrastructure/ValidationException.cs b/src/Squidex.Infrastructure/ValidationException.cs index 163904187..16f3d836c 100644 --- a/src/Squidex.Infrastructure/ValidationException.cs +++ b/src/Squidex.Infrastructure/ValidationException.cs @@ -21,7 +21,7 @@ namespace Squidex.Infrastructure public IReadOnlyList Errors { - get { return errors; } + get { return errors ?? FallbackErrors; } } public string Summary { get; } @@ -38,7 +38,7 @@ namespace Squidex.Infrastructure } public ValidationException(string summary, Exception inner, params ValidationError[] errors) - : this(summary, null, errors?.ToList()) + : this(summary, inner, errors?.ToList()) { } @@ -53,6 +53,17 @@ namespace Squidex.Infrastructure protected ValidationException(SerializationInfo info, StreamingContext context) : base(info, context) { + Summary = info.GetString(nameof(Summary)); + + errors = (List)info.GetValue(nameof(errors), typeof(List)); + } + + public override void GetObjectData(SerializationInfo info, StreamingContext context) + { + info.AddValue(nameof(Summary), Summary); + info.AddValue(nameof(errors), errors.ToList()); + + base.GetObjectData(info, context); } private static string FormatMessage(string summary, IReadOnlyList errors) diff --git a/src/Squidex.Infrastructure/ValueStopwatch.cs b/src/Squidex.Infrastructure/ValueStopwatch.cs new file mode 100644 index 000000000..133cfab83 --- /dev/null +++ b/src/Squidex.Infrastructure/ValueStopwatch.cs @@ -0,0 +1,56 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Diagnostics; + +namespace Squidex.Infrastructure +{ + public struct ValueStopwatch + { + private const long TicksPerMillisecond = 10000; + private const long TicksPerSecond = TicksPerMillisecond * 1000; + + private static readonly double TickFrequency; + + private readonly long startTime; + + static ValueStopwatch() + { + if (Stopwatch.IsHighResolution) + { + TickFrequency = (double)TicksPerSecond / Stopwatch.Frequency; + } + } + + public ValueStopwatch(long startTime) + { + this.startTime = startTime; + } + + public static ValueStopwatch StartNew() + { + return new ValueStopwatch(Stopwatch.GetTimestamp()); + } + + public long Stop() + { + var elapsed = Stopwatch.GetTimestamp() - startTime; + + if (elapsed < 0) + { + return elapsed; + } + + if (Stopwatch.IsHighResolution) + { + elapsed = unchecked((long)(elapsed * TickFrequency)); + } + + return elapsed / TicksPerMillisecond; + } + } +} diff --git a/src/Squidex.Shared/Identity/SquidexClaimTypes.cs b/src/Squidex.Shared/Identity/SquidexClaimTypes.cs index b456cfd21..79d1bbf2d 100644 --- a/src/Squidex.Shared/Identity/SquidexClaimTypes.cs +++ b/src/Squidex.Shared/Identity/SquidexClaimTypes.cs @@ -17,6 +17,8 @@ namespace Squidex.Shared.Identity public static readonly string SquidexConsentForEmails = "urn:squidex:consent:emails"; + public static readonly string SquidexHidden = "urn:squidex:hidden"; + public static readonly string Prefix = "urn:squidex:"; } } diff --git a/src/Squidex.Shared/Squidex.Shared.csproj b/src/Squidex.Shared/Squidex.Shared.csproj index 244e5b12e..62f8ca68e 100644 --- a/src/Squidex.Shared/Squidex.Shared.csproj +++ b/src/Squidex.Shared/Squidex.Shared.csproj @@ -7,8 +7,8 @@ True - - + + @@ -17,4 +17,7 @@ + + + \ No newline at end of file diff --git a/src/Squidex.Shared/Users/IUserResolver.cs b/src/Squidex.Shared/Users/IUserResolver.cs index 6dea877d9..d7b875a4e 100644 --- a/src/Squidex.Shared/Users/IUserResolver.cs +++ b/src/Squidex.Shared/Users/IUserResolver.cs @@ -5,12 +5,15 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; using System.Threading.Tasks; namespace Squidex.Shared.Users { public interface IUserResolver { - Task FindByIdAsync(string id); + Task FindByIdOrEmailAsync(string idOrEmail); + + Task> QueryByEmailAsync(string email); } } diff --git a/src/Squidex.Shared/Users/UserExtensions.cs b/src/Squidex.Shared/Users/UserExtensions.cs new file mode 100644 index 000000000..42a5b8611 --- /dev/null +++ b/src/Squidex.Shared/Users/UserExtensions.cs @@ -0,0 +1,126 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Linq; +using Squidex.Infrastructure; +using Squidex.Shared.Identity; + +namespace Squidex.Shared.Users +{ + public static class UserExtensions + { + public static void SetDisplayName(this IUser user, string displayName) + { + user.SetClaim(SquidexClaimTypes.SquidexDisplayName, displayName); + } + + public static void SetPictureUrl(this IUser user, string pictureUrl) + { + user.SetClaim(SquidexClaimTypes.SquidexPictureUrl, pictureUrl); + } + + public static void SetPictureUrlToStore(this IUser user) + { + user.SetClaim(SquidexClaimTypes.SquidexPictureUrl, "store"); + } + + public static void SetPictureUrlFromGravatar(this IUser user, string email) + { + user.SetClaim(SquidexClaimTypes.SquidexPictureUrl, GravatarHelper.CreatePictureUrl(email)); + } + + public static void SetHidden(this IUser user, bool value) + { + user.SetClaim(SquidexClaimTypes.SquidexHidden, value.ToString()); + } + + public static void SetConsent(this IUser user) + { + user.SetClaim(SquidexClaimTypes.SquidexConsent, "true"); + } + + public static void SetConsentForEmails(this IUser user, bool value) + { + user.SetClaim(SquidexClaimTypes.SquidexConsentForEmails, value.ToString()); + } + + public static bool IsHidden(this IUser user) + { + return user.HasClaimValue(SquidexClaimTypes.SquidexHidden, "true"); + } + + public static bool HasConsent(this IUser user) + { + return user.HasClaimValue(SquidexClaimTypes.SquidexConsent, "true"); + } + + public static bool HasConsentForEmails(this IUser user) + { + return user.HasClaimValue(SquidexClaimTypes.SquidexConsentForEmails, "true"); + } + + public static bool HasDisplayName(this IUser user) + { + return user.HasClaim(SquidexClaimTypes.SquidexDisplayName); + } + + public static bool HasPictureUrl(this IUser user) + { + return user.HasClaim(SquidexClaimTypes.SquidexPictureUrl); + } + + public static bool IsPictureUrlStored(this IUser user) + { + return user.HasClaimValue(SquidexClaimTypes.SquidexPictureUrl, "store"); + } + + public static string PictureUrl(this IUser user) + { + return user.GetClaimValue(SquidexClaimTypes.SquidexPictureUrl); + } + + public static string DisplayName(this IUser user) + { + return user.GetClaimValue(SquidexClaimTypes.SquidexDisplayName); + } + + public static string GetClaimValue(this IUser user, string claim) + { + return user.Claims.FirstOrDefault(x => string.Equals(x.Type, claim, StringComparison.OrdinalIgnoreCase))?.Value; + } + + public static bool HasClaim(this IUser user, string claim) + { + return user.Claims.Any(x => string.Equals(x.Type, claim, StringComparison.OrdinalIgnoreCase)); + } + + public static bool HasClaimValue(this IUser user, string claim, string value) + { + return user.Claims.Any(x => string.Equals(x.Type, claim, StringComparison.OrdinalIgnoreCase) && string.Equals(x.Value, value, StringComparison.OrdinalIgnoreCase)); + } + + public static string PictureNormalizedUrl(this IUser user) + { + var url = user.Claims.FirstOrDefault(x => x.Type == SquidexClaimTypes.SquidexPictureUrl)?.Value; + + if (!string.IsNullOrWhiteSpace(url) && Uri.IsWellFormedUriString(url, UriKind.Absolute) && url.Contains("gravatar")) + { + if (url.Contains("?")) + { + url += "&d=404"; + } + else + { + url += "?d=404"; + } + } + + return url; + } + } +} diff --git a/src/Squidex/AppConfiguration.cs b/src/Squidex/AppConfiguration.cs deleted file mode 100644 index 6c707bafb..000000000 --- a/src/Squidex/AppConfiguration.cs +++ /dev/null @@ -1,26 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Microsoft.Extensions.Configuration; - -namespace Squidex -{ - public static class AppConfiguration - { - public static void AddAppConfiguration(this IConfigurationBuilder builder, string environmentName, string[] args) - { - builder.Sources.Clear(); - - builder.AddJsonFile("appsettings.json", true, true); - builder.AddJsonFile($"appsettings.{environmentName}.json", true); - - builder.AddEnvironmentVariables(); - - builder.AddCommandLine(args); - } - } -} diff --git a/src/Squidex/AppServices.cs b/src/Squidex/AppServices.cs index baa614181..99d77a706 100644 --- a/src/Squidex/AppServices.cs +++ b/src/Squidex/AppServices.cs @@ -8,11 +8,14 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Squidex.Areas.Api.Config.Swagger; +using Squidex.Areas.Api.Controllers.Contents; using Squidex.Areas.IdentityServer.Config; using Squidex.Config; using Squidex.Config.Authentication; using Squidex.Config.Domain; using Squidex.Config.Web; +using Squidex.Extensions.Actions.Twitter; +using Squidex.Infrastructure.Commands; namespace Squidex { @@ -20,24 +23,35 @@ namespace Squidex { public static void AddAppServices(this IServiceCollection services, IConfiguration config) { + services.AddHttpClient(); services.AddLogging(); services.AddMemoryCache(); services.AddOptions(); services.AddMyAssetServices(config); services.AddMyAuthentication(config); + services.AddMyEntitiesServices(config); services.AddMyEventPublishersServices(config); services.AddMyEventStoreServices(config); services.AddMyIdentityServer(); - services.AddMyInfrastructureServices(config); + services.AddMyInfrastructureServices(); + services.AddMyLoggingServices(config); + services.AddMyMigrationServices(); services.AddMyMvc(); - services.AddMyPubSubServices(config); - services.AddMyReadServices(config); + services.AddMyRuleServices(); services.AddMySerializers(); services.AddMyStoreServices(config); services.AddMySwaggerSettings(); - services.AddMyWriteServices(); + services.AddMySubscriptionServices(config); + services.Configure( + config.GetSection("mode")); + + services.Configure( + config.GetSection("twitter")); + + services.Configure( + config.GetSection("contentsController")); services.Configure( config.GetSection("urls")); services.Configure( diff --git a/src/Squidex/Areas/Api/Config/Swagger/SwaggerExtensions.cs b/src/Squidex/Areas/Api/Config/Swagger/SwaggerExtensions.cs index abda24f26..1cdea31b5 100644 --- a/src/Squidex/Areas/Api/Config/Swagger/SwaggerExtensions.cs +++ b/src/Squidex/Areas/Api/Config/Swagger/SwaggerExtensions.cs @@ -8,7 +8,9 @@ using System.Reflection; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using NSwag.AspNetCore; +using Squidex.Config; namespace Squidex.Areas.Api.Config.Swagger { @@ -16,9 +18,16 @@ namespace Squidex.Areas.Api.Config.Swagger { public static void UseMySwagger(this IApplicationBuilder app) { - var settings = app.ApplicationServices.GetService(); + var urlOptions = app.ApplicationServices.GetService>().Value; - app.UseSwagger(typeof(SwaggerExtensions).GetTypeInfo().Assembly, settings); + app.UseSwagger(typeof(SwaggerExtensions).GetTypeInfo().Assembly, settings => + { + settings.AddAssetODataParams(); + settings.ConfigureNames(); + settings.ConfigurePaths(urlOptions); + settings.ConfigureSchemaSettings(); + settings.ConfigureIdentity(urlOptions); + }); } } } diff --git a/src/Squidex/Areas/Api/Config/Swagger/SwaggerServices.cs b/src/Squidex/Areas/Api/Config/Swagger/SwaggerServices.cs index f04c2e960..e4367b418 100644 --- a/src/Squidex/Areas/Api/Config/Swagger/SwaggerServices.cs +++ b/src/Squidex/Areas/Api/Config/Swagger/SwaggerServices.cs @@ -12,8 +12,10 @@ using NJsonSchema; using NJsonSchema.Generation.TypeMappers; using NodaTime; using NSwag.AspNetCore; +using NSwag.SwaggerGeneration; using NSwag.SwaggerGeneration.Processors.Security; using Squidex.Areas.Api.Controllers.Contents.Generator; +using Squidex.Areas.Api.Controllers.Rules.Models; using Squidex.Config; using Squidex.Infrastructure; using Squidex.Pipeline.Swagger; @@ -24,13 +26,13 @@ namespace Squidex.Areas.Api.Config.Swagger { public static void AddMySwaggerSettings(this IServiceCollection services) { - services.AddSingleton(typeof(SwaggerSettings), s => + services.AddSingleton(typeof(SwaggerSettings), s => { var urlOptions = s.GetService>().Value; - var settings = - new SwaggerSettings { Title = "Squidex API", Version = "1.0", IsAspNetCore = false } + var settings = new SwaggerSettings() .AddAssetODataParams() + .ConfigureNames() .ConfigurePaths(urlOptions) .ConfigureSchemaSettings() .ConfigureIdentity(urlOptions); @@ -41,25 +43,33 @@ namespace Squidex.Areas.Api.Config.Swagger services.AddTransient(); } - private static SwaggerSettings AddAssetODataParams(this SwaggerSettings settings) + public static SwaggerSettings ConfigureNames(this SwaggerSettings settings) where T : SwaggerGeneratorSettings, new() { - settings.OperationProcessors.Add(new ODataQueryParamsProcessor("/apps/{app}/assets", "assets", false)); + settings.GeneratorSettings.Title = "Squidex API"; + settings.GeneratorSettings.Version = "1.0"; return settings; } - private static SwaggerSettings ConfigureIdentity(this SwaggerSettings settings, MyUrlsOptions urlOptions) + public static SwaggerSettings AddAssetODataParams(this SwaggerSettings settings) where T : SwaggerGeneratorSettings, new() { - settings.DocumentProcessors.Add( + settings.GeneratorSettings.OperationProcessors.Add(new ODataQueryParamsProcessor("/apps/{app}/assets", "assets", false)); + + return settings; + } + + public static SwaggerSettings ConfigureIdentity(this SwaggerSettings settings, MyUrlsOptions urlOptions) where T : SwaggerGeneratorSettings, new() + { + settings.GeneratorSettings.DocumentProcessors.Add( new SecurityDefinitionAppender( Constants.SecurityDefinition, SwaggerHelper.CreateOAuthSchema(urlOptions))); - settings.OperationProcessors.Add(new ScopesProcessor()); + settings.GeneratorSettings.OperationProcessors.Add(new ScopesProcessor()); return settings; } - private static SwaggerSettings ConfigurePaths(this SwaggerSettings settings, MyUrlsOptions urlOptions) + public static SwaggerSettings ConfigurePaths(this SwaggerSettings settings, MyUrlsOptions urlOptions) where T : SwaggerGeneratorSettings, new() { settings.SwaggerRoute = $"{Constants.ApiPrefix}/swagger/v1/swagger.json"; @@ -77,12 +87,12 @@ namespace Squidex.Areas.Api.Config.Swagger return settings; } - private static SwaggerSettings ConfigureSchemaSettings(this SwaggerSettings settings) + public static SwaggerSettings ConfigureSchemaSettings(this SwaggerSettings settings) where T : SwaggerGeneratorSettings, new() { - settings.DefaultEnumHandling = EnumHandling.String; - settings.DefaultPropertyNameHandling = PropertyNameHandling.CamelCase; + settings.GeneratorSettings.DefaultEnumHandling = EnumHandling.String; + settings.GeneratorSettings.DefaultPropertyNameHandling = PropertyNameHandling.CamelCase; - settings.TypeMappers = new List + settings.GeneratorSettings.TypeMappers = new List { new PrimitiveTypeMapper(typeof(Instant), schema => { @@ -93,10 +103,11 @@ namespace Squidex.Areas.Api.Config.Swagger new PrimitiveTypeMapper(typeof(RefToken), s => s.Type = JsonObjectType.String) }; - settings.DocumentProcessors.Add(new XmlTagProcessor()); + settings.GeneratorSettings.DocumentProcessors.Add(new RuleActionProcessor()); + settings.GeneratorSettings.DocumentProcessors.Add(new XmlTagProcessor()); - settings.OperationProcessors.Add(new XmlTagProcessor()); - settings.OperationProcessors.Add(new XmlResponseTypesProcessor()); + settings.GeneratorSettings.OperationProcessors.Add(new XmlTagProcessor()); + settings.GeneratorSettings.OperationProcessors.Add(new XmlResponseTypesProcessor()); return settings; } diff --git a/src/Squidex/Areas/Api/Config/Swagger/XmlTagProcessor.cs b/src/Squidex/Areas/Api/Config/Swagger/XmlTagProcessor.cs index 61e6afa33..6e63a03fb 100644 --- a/src/Squidex/Areas/Api/Config/Swagger/XmlTagProcessor.cs +++ b/src/Squidex/Areas/Api/Config/Swagger/XmlTagProcessor.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Linq; using System.Reflection; using System.Threading.Tasks; using NJsonSchema.Infrastructure; @@ -25,7 +26,7 @@ namespace Squidex.Areas.Api.Config.Swagger if (tagAttribute != null) { - var tag = context.Document.Tags.Find(x => x.Name == tagAttribute.Name); + var tag = context.Document.Tags.FirstOrDefault(x => x.Name == tagAttribute.Name); if (tag != null) { diff --git a/src/Squidex/Areas/Api/Controllers/ApiController.cs b/src/Squidex/Areas/Api/Controllers/ApiController.cs index 0108217ee..0d8cee479 100644 --- a/src/Squidex/Areas/Api/Controllers/ApiController.cs +++ b/src/Squidex/Areas/Api/Controllers/ApiController.cs @@ -16,6 +16,7 @@ using Squidex.Pipeline; namespace Squidex.Areas.Api.Controllers { [Area("Api")] + [ApiModelValidation(false)] public abstract class ApiController : Controller { protected ICommandBus CommandBus { get; } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs index bd9b70cc3..dff6510a1 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs @@ -10,10 +10,8 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using NSwag.Annotations; using Squidex.Areas.Api.Controllers.Apps.Models; -using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.Reflection; using Squidex.Pipeline; namespace Squidex.Areas.Api.Controllers.Apps @@ -50,7 +48,7 @@ namespace Squidex.Areas.Api.Controllers.Apps [ApiCosts(0)] public IActionResult GetClients(string app) { - var response = App.Clients.Select(x => SimpleMapper.Map(x.Value, new ClientDto { Id = x.Key })).ToList(); + var response = App.Clients.Select(ClientDto.FromKvp).ToList(); Response.Headers["ETag"] = App.Version.ToString(); @@ -76,11 +74,11 @@ namespace Squidex.Areas.Api.Controllers.Apps [ApiCosts(1)] public async Task PostClient(string app, [FromBody] CreateAppClientDto request) { - var command = SimpleMapper.Map(request, new AttachClient()); + var command = request.ToCommand(); await CommandBus.PublishAsync(command); - var response = SimpleMapper.Map(command, new ClientDto { Name = command.Id, Permission = AppClientPermission.Editor }); + var response = ClientDto.FromCommand(command); return CreatedAtAction(nameof(GetClients), new { app }, response); } @@ -93,7 +91,8 @@ namespace Squidex.Areas.Api.Controllers.Apps /// Client object that needs to be updated. /// /// 204 => Client updated. - /// 404 => App not found or client not found. + /// 400 => Client request not valid. + /// 404 => Client or app not found. /// /// /// Only the display name can be changed, create a new client if necessary. @@ -103,7 +102,7 @@ namespace Squidex.Areas.Api.Controllers.Apps [ApiCosts(1)] public async Task PutClient(string app, string clientId, [FromBody] UpdateAppClientDto request) { - await CommandBus.PublishAsync(SimpleMapper.Map(request, new UpdateClient { Id = clientId })); + await CommandBus.PublishAsync(request.ToCommand(clientId)); return NoContent(); } @@ -115,7 +114,7 @@ namespace Squidex.Areas.Api.Controllers.Apps /// The id of the client that must be deleted. /// /// 204 => Client revoked. - /// 404 => App not found or client not found. + /// 404 => Client or app not found. /// /// /// The application that uses this client credentials cannot access the API after it has been revoked. diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs index aa8cd6ac7..edd1a33f1 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using NSwag.Annotations; @@ -13,7 +12,6 @@ using Squidex.Areas.Api.Controllers.Apps.Models; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Services; using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.Reflection; using Squidex.Pipeline; namespace Squidex.Areas.Api.Controllers.Apps @@ -50,9 +48,7 @@ namespace Squidex.Areas.Api.Controllers.Apps [ApiCosts(0)] public IActionResult GetContributors(string app) { - var contributors = App.Contributors.Select(x => new ContributorDto { ContributorId = x.Key, Permission = x.Value }).ToArray(); - - var response = new ContributorsDto { Contributors = contributors, MaxContributors = appPlansProvider.GetPlanForApp(App).MaxContributors }; + var response = ContributorsDto.FromApp(App, appPlansProvider); Response.Headers["ETag"] = App.Version.ToString(); @@ -65,19 +61,24 @@ namespace Squidex.Areas.Api.Controllers.Apps /// The name of the app. /// Contributor object that needs to be added to the app. /// - /// 204 => User assigned to app. + /// 200 => User assigned to app. /// 400 => User is already assigned to the app or not found. /// 404 => App not found. /// [HttpPost] [Route("apps/{app}/contributors/")] + [ProducesResponseType(typeof(ContributorAssignedDto), 201)] [ProducesResponseType(typeof(ErrorDto), 400)] [ApiCosts(1)] public async Task PostContributor(string app, [FromBody] AssignAppContributorDto request) { - await CommandBus.PublishAsync(SimpleMapper.Map(request, new AssignContributor())); + var command = request.ToCommand(); + var context = await CommandBus.PublishAsync(command); - return NoContent(); + var result = context.Result>(); + var response = ContributorAssignedDto.FromId(result.IdOrValue); + + return Ok(response); } /// @@ -88,7 +89,7 @@ namespace Squidex.Areas.Api.Controllers.Apps /// /// 204 => User removed from app. /// 400 => User is not assigned to the app. - /// 404 => App not found. + /// 404 => Contributor or app not found. /// [HttpDelete] [Route("apps/{app}/contributors/{id}/")] diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs index 53dff0972..a2bcfdab4 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs @@ -6,17 +6,13 @@ // ========================================================================== using System; -using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using NSwag.Annotations; using Squidex.Areas.Api.Controllers.Apps.Models; -using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.Reflection; using Squidex.Pipeline; namespace Squidex.Areas.Api.Controllers.Apps @@ -50,14 +46,7 @@ namespace Squidex.Areas.Api.Controllers.Apps [ApiCosts(0)] public IActionResult GetLanguages(string app) { - var response = App.LanguagesConfig.OfType().Select(x => - SimpleMapper.Map(x.Language, - new AppLanguageDto - { - IsMaster = x == App.LanguagesConfig.Master, - IsOptional = x.IsOptional, - Fallback = x.LanguageFallbacks.ToList() - })).OrderByDescending(x => x.IsMaster).ThenBy(x => x.Iso2Code).ToList(); + var response = AppLanguageDto.FromApp(App); Response.Headers["ETag"] = App.Version.ToString(); @@ -71,7 +60,7 @@ namespace Squidex.Areas.Api.Controllers.Apps /// The language to add to the app. /// /// 201 => Language created. - /// 400 => Language is an invalid language. + /// 400 => Language request not valid. /// 404 => App not found. /// [MustBeAppEditor] @@ -82,9 +71,11 @@ namespace Squidex.Areas.Api.Controllers.Apps [ApiCosts(1)] public async Task PostLanguage(string app, [FromBody] AddAppLanguageDto request) { - await CommandBus.PublishAsync(SimpleMapper.Map(request, new AddLanguage())); + var command = request.ToCommand(); - var response = SimpleMapper.Map(request.Language, new AppLanguageDto { Fallback = new List() }); + await CommandBus.PublishAsync(command); + + var response = AppLanguageDto.FromCommand(command); return CreatedAtAction(nameof(GetLanguages), new { app }, response); } @@ -97,8 +88,8 @@ namespace Squidex.Areas.Api.Controllers.Apps /// The language object. /// /// 204 => Language updated. - /// 400 => Language object is invalid. - /// 404 => App not found. + /// 400 => Language request not valid. + /// 404 => Language or app not found. /// [MustBeAppEditor] [HttpPut] @@ -106,7 +97,7 @@ namespace Squidex.Areas.Api.Controllers.Apps [ApiCosts(1)] public async Task Update(string app, string language, [FromBody] UpdateAppLanguageDto request) { - await CommandBus.PublishAsync(SimpleMapper.Map(request, new UpdateLanguage { Language = language })); + await CommandBus.PublishAsync(request.ToCommand(ParseLanguage(language))); return NoContent(); } @@ -118,7 +109,7 @@ namespace Squidex.Areas.Api.Controllers.Apps /// The language to delete from the app. /// /// 204 => Language deleted. - /// 404 => App not found. + /// 404 => Language or app not found. /// [MustBeAppEditor] [HttpDelete] diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs index 949e8951e..c1cb2c82a 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs @@ -13,7 +13,6 @@ using NSwag.Annotations; using Squidex.Areas.Api.Controllers.Apps.Models; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.Reflection; using Squidex.Pipeline; namespace Squidex.Areas.Api.Controllers.Apps @@ -50,9 +49,9 @@ namespace Squidex.Areas.Api.Controllers.Apps [ApiCosts(0)] public IActionResult GetPatterns(string app) { - var response = - App.Patterns.Select(x => SimpleMapper.Map(x.Value, new AppPatternDto { PatternId = x.Key })) - .OrderBy(x => x.Name).ToList(); + var response = App.Patterns.Select(AppPatternDto.FromKvp).OrderBy(x => x.Name).ToList(); + + Response.Headers["ETag"] = App.Version.ToString(); return Ok(response); } @@ -64,6 +63,7 @@ namespace Squidex.Areas.Api.Controllers.Apps /// Pattern to be added to the app. /// /// 201 => Pattern generated. + /// 400 => Pattern request not valid. /// 404 => App not found. /// [HttpPost] @@ -72,11 +72,11 @@ namespace Squidex.Areas.Api.Controllers.Apps [ApiCosts(1)] public async Task PostPattern(string app, [FromBody] UpdatePatternDto request) { - var command = SimpleMapper.Map(request, new AddPattern()); + var command = request.ToAddCommand(); await CommandBus.PublishAsync(command); - var response = SimpleMapper.Map(request, new AppPatternDto { PatternId = command.PatternId }); + var response = AppPatternDto.FromCommand(command); return CreatedAtAction(nameof(GetPatterns), new { app }, response); } @@ -89,7 +89,8 @@ namespace Squidex.Areas.Api.Controllers.Apps /// Pattern to be updated for the app. /// /// 204 => Pattern updated. - /// 404 => App not found or pattern not found. + /// 400 => Pattern request not valid. + /// 404 => Pattern or app not found. /// [HttpPut] [Route("apps/{app}/patterns/{id}/")] @@ -97,21 +98,19 @@ namespace Squidex.Areas.Api.Controllers.Apps [ApiCosts(1)] public async Task UpdatePattern(string app, Guid id, [FromBody] UpdatePatternDto request) { - var command = SimpleMapper.Map(request, new UpdatePattern { PatternId = id }); - - await CommandBus.PublishAsync(command); + await CommandBus.PublishAsync(request.ToUpdateCommand(id)); return NoContent(); } /// - /// Revoke an app client + /// Revoke an app client. /// /// The name of the app. /// The id of the pattern to be deleted. /// /// 204 => Pattern removed. - /// 404 => App or pattern not found. + /// 404 => Pattern or app not found. /// /// /// Schemas using this pattern will still function using the same Regular Expression diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs index 9ea40a91e..21ec8ad64 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs @@ -11,12 +11,10 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using NSwag.Annotations; using Squidex.Areas.Api.Controllers.Apps.Models; -using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Services; using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Security; using Squidex.Pipeline; @@ -60,19 +58,11 @@ namespace Squidex.Areas.Api.Controllers.Apps { var subject = HttpContext.User.OpenIdSubject(); - var apps = await appProvider.GetUserApps(subject); + var entities = await appProvider.GetUserApps(subject); - var response = apps.Select(a => - { - var dto = SimpleMapper.Map(a, new AppDto()); + var response = entities.Select(a => AppDto.FromApp(a, subject, appPlansProvider)).ToList(); - dto.Permission = a.Contributors[subject]; - - dto.PlanName = appPlansProvider.GetPlanForApp(a)?.Name; - dto.PlanUpgrade = appPlansProvider.GetPlanUpgradeForApp(a)?.Name; - - return dto; - }).ToList(); + Response.Headers["ETag"] = response.ToManyEtag(); return Ok(response); } @@ -83,7 +73,7 @@ namespace Squidex.Areas.Api.Controllers.Apps /// The app object that needs to be added to squidex. /// /// 201 => App created. - /// 400 => App object is not valid. + /// 400 => App request not valid. /// 409 => App name is already in use. /// /// @@ -98,19 +88,32 @@ namespace Squidex.Areas.Api.Controllers.Apps [ApiCosts(1)] public async Task PostApp([FromBody] CreateAppDto request) { - var command = SimpleMapper.Map(request, new CreateApp()); - - var context = await CommandBus.PublishAsync(command); + var context = await CommandBus.PublishAsync(request.ToCommand()); var result = context.Result>(); - var response = new AppCreatedDto { Id = result.IdOrValue.ToString(), Version = result.Version }; + var response = AppCreatedDto.FromResult(result, appPlansProvider); - response.Permission = AppContributorPermission.Owner; + return CreatedAtAction(nameof(GetApps), response); + } - response.PlanName = appPlansProvider.GetPlan(null)?.Name; - response.PlanUpgrade = appPlansProvider.GetPlanUpgrade(null)?.Name; + /// + /// Archive the app. + /// /// + /// The name of the app to archive. + /// + /// 204 => App archived. + /// 404 => App not found. + /// + [HttpDelete] + [Route("apps/{app}/")] + [AppApi] + [ApiCosts(1)] + [MustBeAppOwner] + public async Task DeleteApp(string app) + { + await CommandBus.PublishAsync(new ArchiveApp()); - return CreatedAtAction(nameof(GetApps), response); + return NoContent(); } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/AddAppLanguageDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/AddAppLanguageDto.cs index a3b4f0818..59e89bdbb 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/AddAppLanguageDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/AddAppLanguageDto.cs @@ -6,7 +6,9 @@ // ========================================================================== using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Infrastructure; +using Squidex.Infrastructure.Reflection; namespace Squidex.Areas.Api.Controllers.Apps.Models { @@ -17,5 +19,10 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// [Required] public Language Language { get; set; } + + public AddLanguage ToCommand() + { + return SimpleMapper.Map(this, new AddLanguage()); + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppCreatedDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppCreatedDto.cs index 68f1ba689..5bff6c58b 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppCreatedDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppCreatedDto.cs @@ -5,10 +5,13 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.ComponentModel.DataAnnotations; using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Entities.Apps.Services; +using Squidex.Infrastructure.Commands; namespace Squidex.Areas.Api.Controllers.Apps.Models { @@ -20,17 +23,17 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models [Required] public string Id { get; set; } - /// - /// The new version of the entity. - /// - public long Version { get; set; } - /// /// The permission level of the user. /// [JsonConverter(typeof(StringEnumConverter))] public AppContributorPermission Permission { get; set; } + /// + /// The new version of the entity. + /// + public long Version { get; set; } + /// /// Gets the current plan name. /// @@ -40,5 +43,19 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// Gets the next plan name. /// public string PlanUpgrade { get; set; } + + public static AppCreatedDto FromResult(EntityCreatedResult result, IAppPlansProvider apps) + { + var response = new AppCreatedDto + { + Id = result.IdOrValue.ToString(), + Permission = AppContributorPermission.Owner, + PlanName = apps.GetPlan(null)?.Name, + PlanUpgrade = apps.GetPlanUpgrade(null)?.Name, + Version = result.Version + }; + + return response; + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs index 4ef8651b2..35eab6b6b 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs @@ -11,10 +11,14 @@ using Newtonsoft.Json; using Newtonsoft.Json.Converters; using NodaTime; using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Apps.Services; +using Squidex.Infrastructure.Reflection; +using Squidex.Pipeline; namespace Squidex.Areas.Api.Controllers.Apps.Models { - public sealed class AppDto + public sealed class AppDto : IGenerateEtag { /// /// The name of the app. @@ -58,5 +62,17 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// Gets the next plan name. /// public string PlanUpgrade { get; set; } + + public static AppDto FromApp(IAppEntity app, string subject, IAppPlansProvider plans) + { + var response = SimpleMapper.Map(app, new AppDto()); + + response.Permission = app.Contributors[subject]; + + response.PlanName = plans.GetPlanForApp(app)?.Name; + response.PlanUpgrade = plans.GetPlanUpgradeForApp(app)?.Name; + + return response; + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppLanguageDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppLanguageDto.cs index b85dae011..0a064ebac 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppLanguageDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppLanguageDto.cs @@ -7,7 +7,12 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.Linq; +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Infrastructure; +using Squidex.Infrastructure.Reflection; namespace Squidex.Areas.Api.Controllers.Apps.Models { @@ -40,5 +45,26 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// Indicates if the language is optional. /// public bool IsOptional { get; set; } + + public static AppLanguageDto FromCommand(AddLanguage command) + { + return SimpleMapper.Map(command.Language, new AppLanguageDto { Fallback = new List() }); + } + + public static AppLanguageDto[] FromApp(IAppEntity app) + { + return app.LanguagesConfig.OfType().Select(x => FromLanguage(x, app)).OrderByDescending(x => x.IsMaster).ThenBy(x => x.Iso2Code).ToArray(); + } + + private static AppLanguageDto FromLanguage(LanguageConfig x, IAppEntity app) + { + return SimpleMapper.Map(x.Language, + new AppLanguageDto + { + IsMaster = x == app.LanguagesConfig.Master, + IsOptional = x.IsOptional, + Fallback = x.LanguageFallbacks.ToList() + }); + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppPatternDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppPatternDto.cs index f5bcaf988..c42e21caf 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppPatternDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppPatternDto.cs @@ -6,7 +6,11 @@ // ========================================================================== using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Infrastructure.Reflection; namespace Squidex.Areas.Api.Controllers.Apps.Models { @@ -33,5 +37,15 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// The regex message. /// public string Message { get; set; } + + public static AppPatternDto FromKvp(KeyValuePair kvp) + { + return SimpleMapper.Map(kvp.Value, new AppPatternDto { PatternId = kvp.Key }); + } + + public static AppPatternDto FromCommand(AddPattern command) + { + return SimpleMapper.Map(command, new AppPatternDto()); + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/AssignAppContributorDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/AssignAppContributorDto.cs index ed56a9183..988c36336 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/AssignAppContributorDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/AssignAppContributorDto.cs @@ -9,13 +9,15 @@ using System.ComponentModel.DataAnnotations; using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Infrastructure.Reflection; namespace Squidex.Areas.Api.Controllers.Apps.Models { public sealed class AssignAppContributorDto { /// - /// The id of the user to add to the app. + /// The id or email of the user to add to the app. /// [Required] public string ContributorId { get; set; } @@ -25,5 +27,10 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// [JsonConverter(typeof(StringEnumConverter))] public AppContributorPermission Permission { get; set; } + + public AssignContributor ToCommand() + { + return SimpleMapper.Map(this, new AssignContributor()); + } } } \ No newline at end of file diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/ClientDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/ClientDto.cs index 9858ade75..81252db26 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/ClientDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/ClientDto.cs @@ -5,10 +5,13 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Infrastructure.Reflection; namespace Squidex.Areas.Api.Controllers.Apps.Models { @@ -38,5 +41,15 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models [Required] [JsonConverter(typeof(StringEnumConverter))] public AppClientPermission Permission { get; set; } + + public static ClientDto FromKvp(KeyValuePair kvp) + { + return SimpleMapper.Map(kvp.Value, new ClientDto { Id = kvp.Key }); + } + + public static ClientDto FromCommand(AttachClient command) + { + return SimpleMapper.Map(command, new ClientDto { Name = command.Id, Permission = AppClientPermission.Editor }); + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorAssignedDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorAssignedDto.cs new file mode 100644 index 000000000..2d8738d41 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorAssignedDto.cs @@ -0,0 +1,25 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; + +namespace Squidex.Areas.Api.Controllers.Apps.Models +{ + public sealed class ContributorAssignedDto + { + /// + /// The id of the user that has been assigned as contributor. + /// + [Required] + public string ContributorId { get; set; } + + public static ContributorAssignedDto FromId(string id) + { + return new ContributorAssignedDto { ContributorId = id }; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorsDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorsDto.cs index 4d411b57f..2251382f8 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorsDto.cs @@ -6,6 +6,9 @@ // ========================================================================== using System.ComponentModel.DataAnnotations; +using System.Linq; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Apps.Services; namespace Squidex.Areas.Api.Controllers.Apps.Models { @@ -21,5 +24,14 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// The maximum number of allowed contributors. /// public int MaxContributors { get; set; } + + public static ContributorsDto FromApp(IAppEntity app, IAppPlansProvider plans) + { + var plan = plans.GetPlanForApp(app); + + var contributors = app.Contributors.Select(x => new ContributorDto { ContributorId = x.Key, Permission = x.Value }).ToArray(); + + return new ContributorsDto { Contributors = contributors, MaxContributors = plan.MaxContributors }; + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/CreateAppClientDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/CreateAppClientDto.cs index c9ea73b54..c7ba2a04f 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/CreateAppClientDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/CreateAppClientDto.cs @@ -6,6 +6,8 @@ // ========================================================================== using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Infrastructure.Reflection; namespace Squidex.Areas.Api.Controllers.Apps.Models { @@ -17,5 +19,10 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models [Required] [RegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")] public string Id { get; set; } + + public AttachClient ToCommand() + { + return SimpleMapper.Map(this, new AttachClient()); + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/CreateAppDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/CreateAppDto.cs index 92cab9c82..52a769f50 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/CreateAppDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/CreateAppDto.cs @@ -6,6 +6,8 @@ // ========================================================================== using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Infrastructure.Reflection; namespace Squidex.Areas.Api.Controllers.Apps.Models { @@ -22,5 +24,10 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// Initialize the app with the inbuilt template. /// public string Template { get; set; } + + public CreateApp ToCommand() + { + return SimpleMapper.Map(this, new CreateApp()); + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateAppClientDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateAppClientDto.cs index c366d50ef..fa6e3a16c 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateAppClientDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateAppClientDto.cs @@ -9,6 +9,8 @@ using System.ComponentModel.DataAnnotations; using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Infrastructure.Reflection; namespace Squidex.Areas.Api.Controllers.Apps.Models { @@ -25,5 +27,10 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// [JsonConverter(typeof(StringEnumConverter))] public AppClientPermission? Permission { get; set; } + + public UpdateClient ToCommand(string clientId) + { + return SimpleMapper.Map(this, new UpdateClient { Id = clientId }); + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateAppLanguageDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateAppLanguageDto.cs index 5f888a970..83d7fc5a7 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateAppLanguageDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateAppLanguageDto.cs @@ -6,7 +6,9 @@ // ========================================================================== using System.Collections.Generic; +using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Infrastructure; +using Squidex.Infrastructure.Reflection; namespace Squidex.Areas.Api.Controllers.Apps.Models { @@ -26,5 +28,10 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// Optional fallback languages. /// public List Fallback { get; set; } + + public UpdateLanguage ToCommand(Language language) + { + return SimpleMapper.Map(this, new UpdateLanguage { Language = language }); + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdatePatternDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdatePatternDto.cs index 76e75f116..4c225880f 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdatePatternDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdatePatternDto.cs @@ -5,7 +5,10 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Infrastructure.Reflection; namespace Squidex.Areas.Api.Controllers.Apps.Models { @@ -27,5 +30,15 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// The regex message. /// public string Message { get; set; } + + public AddPattern ToAddCommand() + { + return SimpleMapper.Map(this, new AddPattern()); + } + + public UpdatePattern ToUpdateCommand(Guid id) + { + return SimpleMapper.Map(this, new UpdatePattern { PatternId = id }); + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs b/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs index f0e4c4d03..d443cc17a 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs @@ -27,18 +27,18 @@ namespace Squidex.Areas.Api.Controllers.Assets [SwaggerTag(nameof(Assets))] public sealed class AssetContentController : ApiController { - private readonly IAssetStore assetStorage; + private readonly IAssetStore assetStore; private readonly IAssetRepository assetRepository; private readonly IAssetThumbnailGenerator assetThumbnailGenerator; public AssetContentController( ICommandBus commandBus, - IAssetStore assetStorage, + IAssetStore assetStore, IAssetRepository assetRepository, IAssetThumbnailGenerator assetThumbnailGenerator) : base(commandBus) { - this.assetStorage = assetStorage; + this.assetStore = assetStore; this.assetRepository = assetRepository; this.assetThumbnailGenerator = assetThumbnailGenerator; } @@ -46,21 +46,20 @@ namespace Squidex.Areas.Api.Controllers.Assets /// /// Get the asset content. /// - /// The name of the app. /// The id of the asset. /// The optional version of the asset. /// The target width of the asset, if it is an image. /// The target height of the asset, if it is an image. /// The resize mode when the width and height is defined. /// - /// 200 => Asset found and content or (resize) image returned. + /// 200 => Asset found and content or (resized) image returned. /// 404 => Asset or app not found. /// [HttpGet] [Route("assets/{id}/")] [ProducesResponseType(200)] [ApiCosts(0.5)] - public async Task GetAssetContent(string app, Guid id, [FromQuery] int version = -1, [FromQuery] int? width = null, [FromQuery] int? height = null, [FromQuery] string mode = null) + public async Task GetAssetContent(Guid id, [FromQuery] int version = -1, [FromQuery] int? width = null, [FromQuery] int? height = null, [FromQuery] string mode = null) { var entity = await assetRepository.FindAssetAsync(id); @@ -79,7 +78,7 @@ namespace Squidex.Areas.Api.Controllers.Assets try { - await assetStorage.DownloadAsync(assetId, entity.FileVersion, assetSuffix, bodyStream); + await assetStore.DownloadAsync(assetId, entity.FileVersion, assetSuffix, bodyStream); } catch (AssetNotFoundException) { @@ -87,13 +86,13 @@ namespace Squidex.Areas.Api.Controllers.Assets { using (var destinationStream = GetTempStream()) { - await assetStorage.DownloadAsync(assetId, entity.FileVersion, null, sourceStream); + await assetStore.DownloadAsync(assetId, entity.FileVersion, null, sourceStream); sourceStream.Position = 0; await assetThumbnailGenerator.CreateThumbnailAsync(sourceStream, destinationStream, width, height, mode); destinationStream.Position = 0; - await assetStorage.UploadAsync(assetId, entity.FileVersion, assetSuffix, destinationStream); + await assetStore.UploadAsync(assetId, entity.FileVersion, assetSuffix, destinationStream); destinationStream.Position = 0; await destinationStream.CopyToAsync(bodyStream); @@ -103,7 +102,7 @@ namespace Squidex.Areas.Api.Controllers.Assets } else { - await assetStorage.DownloadAsync(assetId, entity.FileVersion, null, bodyStream); + await assetStore.DownloadAsync(assetId, entity.FileVersion, null, bodyStream); } }); } diff --git a/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs b/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs index 327a28e8f..ece13037b 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs @@ -7,13 +7,15 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using NSwag.Annotations; using Squidex.Areas.Api.Controllers.Assets.Models; +using Squidex.Areas.Api.Controllers.Contents; +using Squidex.Domain.Apps.Core.Tags; +using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Apps.Services; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets.Commands; @@ -21,7 +23,6 @@ using Squidex.Domain.Apps.Entities.Assets.Repositories; using Squidex.Infrastructure; using Squidex.Infrastructure.Assets; using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.Reflection; using Squidex.Pipeline; namespace Squidex.Areas.Api.Controllers.Assets @@ -35,23 +36,52 @@ namespace Squidex.Areas.Api.Controllers.Assets [SwaggerTag(nameof(Assets))] public sealed class AssetsController : ApiController { - private readonly IAssetRepository assetRepository; + private readonly IAssetQueryService assetQuery; private readonly IAssetStatsRepository assetStatsRepository; private readonly IAppPlansProvider appPlanProvider; + private readonly IOptions controllerOptions; + private readonly ITagService tagService; private readonly AssetConfig assetsConfig; public AssetsController( ICommandBus commandBus, - IAssetRepository assetRepository, + IAssetQueryService assetQuery, IAssetStatsRepository assetStatsRepository, IAppPlansProvider appPlanProvider, - IOptions assetsConfig) + IOptions assetsConfig, + IOptions controllerOptions, + ITagService tagService) : base(commandBus) { this.assetsConfig = assetsConfig.Value; - this.assetRepository = assetRepository; + this.assetQuery = assetQuery; this.assetStatsRepository = assetStatsRepository; this.appPlanProvider = appPlanProvider; + this.controllerOptions = controllerOptions; + this.tagService = tagService; + } + + /// + /// Get assets tags. + /// + /// The name of the app. + /// + /// 200 => Assets returned. + /// 404 => App not found. + /// + /// + /// Get all tags for assets. + /// + [MustBeAppReader] + [HttpGet] + [Route("apps/{app}/assets/tags")] + [ProducesResponseType(typeof(Dictionary), 200)] + [ApiCosts(1)] + public async Task GetTags(string app) + { + var response = await tagService.GetTagsAsync(App.Id, TagGroups.Assets); + + return Ok(response); } /// @@ -73,33 +103,18 @@ namespace Squidex.Areas.Api.Controllers.Assets [ApiCosts(1)] public async Task GetAssets(string app, [FromQuery] string ids = null) { - HashSet idsList = null; + var context = Context(); - if (!string.IsNullOrWhiteSpace(ids)) - { - idsList = new HashSet(); - - foreach (var id in ids.Split(',')) - { - if (Guid.TryParse(id, out var guid)) - { - idsList.Add(guid); - } - } - } + var assets = await assetQuery.QueryAsync(context, Q.Empty.WithODataQuery(Request.QueryString.ToString()).WithIds(ids)); - var assets = - idsList?.Count > 0 ? - await assetRepository.QueryAsync(App.Id, idsList) : - await assetRepository.QueryAsync(App.Id, Request.QueryString.ToString()); + var response = AssetsDto.FromAssets(assets); - var response = new AssetsDto + if (controllerOptions.Value.EnableSurrogateKeys && response.Items.Length <= controllerOptions.Value.MaxItemsForSurrogateKeys) { - Total = assets.Total, - Items = assets.Select(x => SimpleMapper.Map(x, new AssetDto { FileType = x.FileName.FileType() })).ToArray() - }; + Response.Headers["Surrogate-Key"] = response.Items.ToSurrogateKeys(); + } - Response.Headers["Surrogate-Key"] = string.Join(" ", response.Items.Select(x => x.Id)); + Response.Headers["ETag"] = response.Items.ToManyEtag(response.Total); return Ok(response); } @@ -120,17 +135,23 @@ namespace Squidex.Areas.Api.Controllers.Assets [ApiCosts(1)] public async Task GetAsset(string app, Guid id) { - var entity = await assetRepository.FindAssetAsync(id); + var context = Context(); + + var entity = await assetQuery.FindAssetAsync(context, id); if (entity == null) { return NotFound(); } - var response = SimpleMapper.Map(entity, new AssetDto { FileType = entity.FileName.FileType() }); + var response = AssetDto.FromAsset(entity); + + if (controllerOptions.Value.EnableSurrogateKeys) + { + Response.Headers["Surrogate-Key"] = entity.Id.ToString(); + } Response.Headers["ETag"] = entity.Version.ToString(); - Response.Headers["Surrogate-Key"] = entity.Id.ToString(); return Ok(response); } @@ -160,8 +181,8 @@ namespace Squidex.Areas.Api.Controllers.Assets var command = new CreateAsset { File = assetFile }; var context = await CommandBus.PublishAsync(command); - var result = context.Result>(); - var response = AssetCreatedDto.Create(command, result); + var result = context.Result(); + var response = AssetCreatedDto.FromCommand(command, result); return StatusCode(201, response); } @@ -217,9 +238,7 @@ namespace Squidex.Areas.Api.Controllers.Assets [ApiCosts(1)] public async Task PutAsset(string app, Guid id, [FromBody] AssetUpdateDto request) { - var command = SimpleMapper.Map(request, new RenameAsset { AssetId = id }); - - await CommandBus.PublishAsync(command); + await CommandBus.PublishAsync(request.ToCommand(id)); return NoContent(); } @@ -277,5 +296,10 @@ namespace Squidex.Areas.Api.Controllers.Assets return assetFile; } + + private QueryContext Context() + { + return QueryContext.Create(App, User); + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetCreatedDto.cs b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetCreatedDto.cs index 58255d875..428435d2a 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetCreatedDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetCreatedDto.cs @@ -6,9 +6,11 @@ // ========================================================================== using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets.Commands; -using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure; namespace Squidex.Areas.Api.Controllers.Assets.Models { @@ -37,6 +39,12 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models [Required] public string MimeType { get; set; } + /// + /// The default tags. + /// + [Required] + public HashSet Tags { get; set; } + /// /// The size of the file in bytes. /// @@ -67,18 +75,20 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models /// public long Version { get; set; } - public static AssetCreatedDto Create(CreateAsset command, EntityCreatedResult result) + public static AssetCreatedDto FromCommand(CreateAsset command, AssetCreatedResult result) { var response = new AssetCreatedDto { Id = command.AssetId, FileName = command.File.FileName, FileSize = command.File.FileSize, + FileType = command.File.FileName.FileType(), FileVersion = result.Version, MimeType = command.File.MimeType, IsImage = command.ImageInfo != null, PixelWidth = command.ImageInfo?.PixelWidth, PixelHeight = command.ImageInfo?.PixelHeight, + Tags = result.Tags, Version = result.Version }; diff --git a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs index 33400ea80..433a6845c 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs @@ -6,13 +6,17 @@ // ========================================================================== using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using NodaTime; +using Squidex.Domain.Apps.Entities.Assets; using Squidex.Infrastructure; +using Squidex.Infrastructure.Reflection; +using Squidex.Pipeline; namespace Squidex.Areas.Api.Controllers.Assets.Models { - public sealed class AssetDto + public sealed class AssetDto : IGenerateEtag { /// /// The id of the asset. @@ -37,6 +41,11 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models [Required] public string FileType { get; set; } + /// + /// The asset tags. + /// + public HashSet Tags { get; set; } + /// /// The size of the file in bytes. /// @@ -88,5 +97,10 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models /// The version of the asset. /// public long Version { get; set; } + + public static AssetDto FromAsset(IAssetEntity asset) + { + return SimpleMapper.Map(asset, new AssetDto { FileType = asset.FileName.FileType() }); + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetUpdateDto.cs b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetUpdateDto.cs index ca143f5cf..61b4e956b 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetUpdateDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetUpdateDto.cs @@ -5,7 +5,10 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Entities.Assets.Commands; namespace Squidex.Areas.Api.Controllers.Assets.Models { @@ -16,5 +19,23 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models /// [Required] public string FileName { get; set; } + + /// + /// The new asset tags. + /// + [Required] + public HashSet Tags { get; set; } + + public AssetCommand ToCommand(Guid id) + { + if (Tags != null) + { + return new TagAsset { AssetId = id, Tags = Tags }; + } + else + { + return new RenameAsset { AssetId = id, FileName = FileName }; + } + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetsDto.cs b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetsDto.cs index 576db9b8a..6e81fa113 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetsDto.cs @@ -5,18 +5,29 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Infrastructure; + namespace Squidex.Areas.Api.Controllers.Assets.Models { public sealed class AssetsDto { /// - /// The total number of assets. + /// The assets. /// - public long Total { get; set; } + [Required] + public AssetDto[] Items { get; set; } /// - /// The assets. + /// The total number of assets. /// - public AssetDto[] Items { get; set; } + public long Total { get; set; } + + public static AssetsDto FromAssets(IResultList assets) + { + return new AssetsDto { Total = assets.Total, Items = assets.Select(AssetDto.FromAsset).ToArray() }; + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Backups/BackupContentController.cs b/src/Squidex/Areas/Api/Controllers/Backups/BackupContentController.cs new file mode 100644 index 000000000..0db366eef --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Backups/BackupContentController.cs @@ -0,0 +1,54 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Microsoft.AspNetCore.Mvc; +using NSwag.Annotations; +using Squidex.Infrastructure.Assets; +using Squidex.Infrastructure.Commands; +using Squidex.Pipeline; + +namespace Squidex.Areas.Api.Controllers.Backups +{ + /// + /// Manages backups for app. + /// + [ApiExceptionFilter] + [AppApi] + [SwaggerTag(nameof(Backups))] + public class BackupContentController : ApiController + { + private readonly IAssetStore assetStore; + + public BackupContentController(ICommandBus commandBus, IAssetStore assetStore) + : base(commandBus) + { + this.assetStore = assetStore; + } + + /// + /// Get the backup content. + /// + /// The name of the app. + /// The id of the asset. + /// + /// 200 => Backup found and content returned. + /// 404 => Backup or app not found. + /// + [HttpGet] + [Route("apps/{app}/backups/{id}")] + [ProducesResponseType(200)] + [ApiCosts(0)] + public IActionResult GetBackupContent(string app, Guid id) + { + return new FileCallbackResult("application/zip", "Backup.zip", bodyStream => + { + return assetStore.DownloadAsync(id.ToString(), 0, null, bodyStream); + }); + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs b/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs new file mode 100644 index 000000000..dc05949d1 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs @@ -0,0 +1,107 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using NSwag.Annotations; +using Orleans; +using Squidex.Areas.Api.Controllers.Backups.Models; +using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Tasks; +using Squidex.Pipeline; + +namespace Squidex.Areas.Api.Controllers.Backups +{ + /// + /// Manages backups for app. + /// + [ApiAuthorize] + [ApiExceptionFilter] + [AppApi] + [MustBeAppOwner] + [SwaggerTag(nameof(Backups))] + public class BackupsController : ApiController + { + private readonly IGrainFactory grainFactory; + + public BackupsController(ICommandBus commandBus, IGrainFactory grainFactory) + : base(commandBus) + { + this.grainFactory = grainFactory; + } + + /// + /// Get all backup jobs. + /// + /// The name of the app. + /// + /// 200 => Backups returned. + /// 404 => App not found. + /// + [HttpGet] + [Route("apps/{app}/backups/")] + [ProducesResponseType(typeof(List), 200)] + [ApiCosts(0)] + public async Task GetJobs(string app) + { + var backupGrain = grainFactory.GetGrain(App.Id); + + var jobs = await backupGrain.GetStateAsync(); + + var response = jobs.Value.Select(BackupJobDto.FromBackup).ToList(); + + return Ok(response); + } + + /// + /// Start a new backup. + /// + /// The name of the app. + /// + /// 204 => Backup started. + /// 404 => App not found. + /// + [HttpPost] + [Route("apps/{app}/backups/")] + [ProducesResponseType(typeof(List), 200)] + [ApiCosts(0)] + public IActionResult PostBackup(string app) + { + var backupGrain = grainFactory.GetGrain(App.Id); + + backupGrain.RunAsync().Forget(); + + return NoContent(); + } + + /// + /// Delete a backup. + /// + /// The name of the app. + /// The id of the backup to delete. + /// + /// 204 => Backup started. + /// 404 => Backup or app not found. + /// + [HttpDelete] + [Route("apps/{app}/backups/{id}")] + [ProducesResponseType(typeof(List), 200)] + [ApiCosts(0)] + public async Task DeleteBackup(string app, Guid id) + { + var backupGrain = grainFactory.GetGrain(App.Id); + + await backupGrain.DeleteAsync(id); + + return NoContent(); + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs b/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs new file mode 100644 index 000000000..475f4b059 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs @@ -0,0 +1,52 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using NodaTime; +using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Areas.Api.Controllers.Backups.Models +{ + public sealed class BackupJobDto + { + /// + /// The id of the backup job. + /// + public Guid Id { get; set; } + + /// + /// The time when the job has been started. + /// + public Instant Started { get; set; } + + /// + /// The time when the job has been stopped. + /// + public Instant? Stopped { get; set; } + + /// + /// The number of handled events. + /// + public int HandledEvents { get; set; } + + /// + /// The number of handled assets. + /// + public int HandledAssets { get; set; } + + /// + /// The status of the operation. + /// + public JobStatus Status { get; set; } + + public static BackupJobDto FromBackup(IBackupJob backup) + { + return SimpleMapper.Map(backup, new BackupJobDto()); + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreJobDto.cs b/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreJobDto.cs new file mode 100644 index 000000000..d28d77ab0 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreJobDto.cs @@ -0,0 +1,51 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using NodaTime; +using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Areas.Api.Controllers.Backups.Models +{ + public sealed class RestoreJobDto + { + /// + /// The uri to load from. + /// + [Required] + public Uri Url { get; set; } + + /// + /// The status log. + /// + [Required] + public List Log { get; set; } + + /// + /// The time when the job has been started. + /// + public Instant Started { get; set; } + + /// + /// The time when the job has been stopped. + /// + public Instant? Stopped { get; set; } + + /// + /// The status of the operation. + /// + public JobStatus Status { get; set; } + + public static RestoreJobDto FromJob(IRestoreJob job) + { + return SimpleMapper.Map(job, new RestoreJobDto()); + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreRequest.cs b/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreRequest.cs new file mode 100644 index 000000000..a6b103a05 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreRequest.cs @@ -0,0 +1,27 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.ComponentModel.DataAnnotations; + +namespace Squidex.Areas.Api.Controllers.Backups.Models +{ + public sealed class RestoreRequest + { + /// + /// The name of the app. + /// + [RegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")] + public string Name { get; set; } + + /// + /// The url to the restore file. + /// + [Required] + public Uri Url { get; set; } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs b/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs new file mode 100644 index 000000000..18fa86278 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs @@ -0,0 +1,69 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using NSwag.Annotations; +using Orleans; +using Squidex.Areas.Api.Controllers.Backups.Models; +using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Security; +using Squidex.Pipeline; + +namespace Squidex.Areas.Api.Controllers.Backups +{ + /// + /// Restores backups. + /// + [ApiAuthorize] + [ApiExceptionFilter] + [ApiModelValidation(true)] + [MustBeAdministrator] + [SwaggerIgnore] + public class RestoreController : ApiController + { + private readonly IGrainFactory grainFactory; + + public RestoreController(ICommandBus commandBus, IGrainFactory grainFactory) + : base(commandBus) + { + this.grainFactory = grainFactory; + } + + [HttpGet] + [Route("apps/restore/")] + [ApiCosts(0)] + public async Task GetJob() + { + var restoreGrain = grainFactory.GetGrain(User.OpenIdSubject()); + + var job = await restoreGrain.GetJobAsync(); + + if (job.Value == null) + { + return NotFound(); + } + + var response = RestoreJobDto.FromJob(job.Value); + + return Ok(response); + } + + [HttpPost] + [Route("apps/restore/")] + [ApiCosts(0)] + public async Task PostRestore([FromBody] RestoreRequest request) + { + var restoreGrain = grainFactory.GetGrain(User.OpenIdSubject()); + + await restoreGrain.RestoreAsync(request.Url, request.Name); + + return NoContent(); + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs b/src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs deleted file mode 100644 index f5a497941..000000000 --- a/src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs +++ /dev/null @@ -1,478 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc; -using NodaTime; -using NodaTime.Text; -using NSwag.Annotations; -using Squidex.Areas.Api.Controllers.Contents.Models; -using Squidex.Domain.Apps.Core.Contents; -using Squidex.Domain.Apps.Core.ConvertContent; -using Squidex.Domain.Apps.Entities.Contents; -using Squidex.Domain.Apps.Entities.Contents.Commands; -using Squidex.Domain.Apps.Entities.Contents.GraphQL; -using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.Reflection; -using Squidex.Pipeline; - -namespace Squidex.Areas.Api.Controllers.Contents -{ - [ApiAuthorize] - [ApiExceptionFilter] - [AppApi] - [SwaggerIgnore] - public sealed class ContentsController : ApiController - { - private readonly IContentQueryService contentQuery; - private readonly IGraphQLService graphQl; - - public ContentsController(ICommandBus commandBus, - IContentQueryService contentQuery, - IGraphQLService graphQl) - : base(commandBus) - { - this.contentQuery = contentQuery; - - this.graphQl = graphQl; - } - - /// - /// GraphQL endpoint. - /// - /// The name of the app. - /// The graphql endpoint. - /// - /// 200 => Contents retrieved or mutated. - /// 404 => Schema or app not found. - /// - /// - /// You can read the generated documentation for your app at /api/content/{appName}/docs - /// - [MustBeAppReader] - [HttpGet] - [HttpPost] - [Route("content/{app}/graphql/")] - [ApiCosts(2)] - public async Task PostGraphQL(string app, [FromBody] GraphQLQuery query) - { - var result = await graphQl.QueryAsync(App, User, query); - - if (result.Errors?.Length > 0) - { - return BadRequest(new { result.Data, result.Errors }); - } - else - { - return Ok(new { result.Data }); - } - } - - /// - /// Queries contents. - /// - /// The name of the app. - /// The name of the schema. - /// The optional ids of the content to fetch. - /// Indicates whether to query content items from the archive. - /// - /// 200 => Contents retrieved. - /// 404 => Schema or app not found. - /// - /// - /// You can read the generated documentation for your app at /api/content/{appName}/docs - /// - [MustBeAppReader] - [HttpGet] - [Route("content/{app}/{name}/")] - [ApiCosts(2)] - public async Task GetContents(string app, string name, [FromQuery] bool archived = false, [FromQuery] string ids = null) - { - HashSet idsList = null; - - if (!string.IsNullOrWhiteSpace(ids)) - { - idsList = new HashSet(); - - foreach (var id in ids.Split(',')) - { - if (Guid.TryParse(id, out var guid)) - { - idsList.Add(guid); - } - } - } - - var isFrontendClient = User.IsFrontendClient(); - - var result = - idsList?.Count > 0 ? - await contentQuery.QueryAsync(App, name, User, archived, idsList) : - await contentQuery.QueryAsync(App, name, User, archived, Request.QueryString.ToString()); - - var response = new ContentsDto - { - Total = result.Contents.Total, - Items = result.Contents.Take(200).Select(item => - { - var itemModel = SimpleMapper.Map(item, new ContentDto()); - - if (item.Data != null) - { - itemModel.Data = item.Data.ToApiModel(result.Schema.SchemaDef, App.LanguagesConfig, !isFrontendClient); - } - - return itemModel; - }).ToArray() - }; - - Response.Headers["Surrogate-Key"] = string.Join(" ", response.Items.Select(x => x.Id)); - - return Ok(response); - } - - /// - /// Get a content item. - /// - /// The name of the app. - /// The name of the schema. - /// The id of the content to fetch. - /// - /// 200 => Content found. - /// 404 => Content, schema or app not found. - /// - /// - /// You can read the generated documentation for your app at /api/content/{appName}/docs - /// - [MustBeAppReader] - [HttpGet] - [Route("content/{app}/{name}/{id}/")] - [ApiCosts(1)] - public async Task GetContent(string app, string name, Guid id) - { - var (schema, entity) = await contentQuery.FindContentAsync(App, name, User, id); - - var response = SimpleMapper.Map(entity, new ContentDto()); - - if (entity.Data != null) - { - var isFrontendClient = User.IsFrontendClient(); - - response.Data = entity.Data.ToApiModel(schema.SchemaDef, App.LanguagesConfig, !isFrontendClient); - } - - Response.Headers["ETag"] = entity.Version.ToString(); - Response.Headers["Surrogate-Key"] = entity.Id.ToString(); - - return Ok(response); - } - - /// - /// Get a content item with a specific version. - /// - /// The name of the app. - /// The name of the schema. - /// The id of the content to fetch. - /// The version fo the content to fetch. - /// - /// 200 => Content found. - /// 404 => Content, schema or app not found. - /// 400 => Content data is not valid. - /// - /// - /// You can read the generated documentation for your app at /api/content/{appName}/docs - /// - [MustBeAppReader] - [HttpGet] - [Route("content/{app}/{name}/{id}/{version}/")] - [ApiCosts(1)] - public async Task GetContentVersion(string app, string name, Guid id, int version) - { - var content = await contentQuery.FindContentAsync(App, name, User, id, version); - - var response = SimpleMapper.Map(content.Content, new ContentDto()); - - if (content.Content.Data != null) - { - var isFrontendClient = User.IsFrontendClient(); - - response.Data = content.Content.Data.ToApiModel(content.Schema.SchemaDef, App.LanguagesConfig, !isFrontendClient); - } - - Response.Headers["ETag"] = version.ToString(); - - return Ok(response.Data); - } - - /// - /// Create a content item. - /// - /// The name of the app. - /// The name of the schema. - /// The full data for the content item. - /// Indicates whether the content should be published immediately. - /// - /// 201 => Content created. - /// 404 => Content, schema or app not found. - /// 400 => Content data is not valid. - /// - /// - /// You can read the generated documentation for your app at /api/content/{appName}/docs - /// - [MustBeAppEditor] - [HttpPost] - [Route("content/{app}/{name}/")] - [ApiCosts(1)] - public async Task PostContent(string app, string name, [FromBody] NamedContentData request, [FromQuery] bool publish = false) - { - await contentQuery.FindSchemaAsync(App, name); - - var command = new CreateContent { ContentId = Guid.NewGuid(), Data = request.ToCleaned(), Publish = publish }; - - var context = await CommandBus.PublishAsync(command); - - var result = context.Result>(); - var response = ContentDto.Create(command, result); - - return CreatedAtAction(nameof(GetContent), new { id = command.ContentId }, response); - } - - /// - /// Update a content item. - /// - /// The name of the app. - /// The name of the schema. - /// The id of the content item to update. - /// The full data for the content item. - /// - /// 200 => Content updated. - /// 404 => Content, schema or app not found. - /// 400 => Content data is not valid. - /// - /// - /// You can read the generated documentation for your app at /api/content/{appName}/docs - /// - [MustBeAppEditor] - [HttpPut] - [Route("content/{app}/{name}/{id}/")] - [ApiCosts(1)] - public async Task PutContent(string app, string name, Guid id, [FromBody] NamedContentData request) - { - await contentQuery.FindSchemaAsync(App, name); - - var command = new UpdateContent { ContentId = id, Data = request.ToCleaned() }; - - var context = await CommandBus.PublishAsync(command); - - var result = context.Result(); - var response = result.Data; - - return Ok(response); - } - - /// - /// Patchs a content item. - /// - /// The name of the app. - /// The name of the schema. - /// The id of the content item to patch. - /// The patch for the content item. - /// - /// 200 => Content patched. - /// 404 => Content, schema or app not found. - /// 400 => Content patch is not valid. - /// - /// - /// You can read the generated documentation for your app at /api/content/{appName}/docs - /// - [MustBeAppEditor] - [HttpPatch] - [Route("content/{app}/{name}/{id}/")] - [ApiCosts(1)] - public async Task PatchContent(string app, string name, Guid id, [FromBody] NamedContentData request) - { - await contentQuery.FindSchemaAsync(App, name); - - var command = new PatchContent { ContentId = id, Data = request.ToCleaned() }; - - var context = await CommandBus.PublishAsync(command); - - var result = context.Result(); - var response = result.Data; - - return Ok(response); - } - - /// - /// Publish a content item. - /// - /// The name of the app. - /// The name of the schema. - /// The id of the content item to publish. - /// The date and time when the content should be published. - /// - /// 204 => Content published. - /// 404 => Content, schema or app not found. - /// 400 => Content was already published. - /// - /// - /// You can read the generated documentation for your app at /api/content/{appName}/docs - /// - [MustBeAppEditor] - [HttpPut] - [Route("content/{app}/{name}/{id}/publish/")] - [ApiCosts(1)] - public async Task PublishContent(string app, string name, Guid id, string dueTime = null) - { - await contentQuery.FindSchemaAsync(App, name); - - var command = CreateCommand(id, Status.Published, dueTime); - - await CommandBus.PublishAsync(command); - - return NoContent(); - } - - /// - /// Unpublish a content item. - /// - /// The name of the app. - /// The name of the schema. - /// The id of the content item to unpublish. - /// The date and time when the content should be unpublished. - /// - /// 204 => Content unpublished. - /// 404 => Content, schema or app not found. - /// 400 => Content was not published. - /// - /// - /// You can read the generated documentation for your app at /api/content/{appName}/docs - /// - [MustBeAppEditor] - [HttpPut] - [Route("content/{app}/{name}/{id}/unpublish/")] - [ApiCosts(1)] - public async Task UnpublishContent(string app, string name, Guid id, string dueTime = null) - { - await contentQuery.FindSchemaAsync(App, name); - - var command = CreateCommand(id, Status.Draft, dueTime); - - await CommandBus.PublishAsync(command); - - return NoContent(); - } - - /// - /// Archive a content item. - /// - /// The name of the app. - /// The name of the schema. - /// The id of the content item to archive. - /// The date and time when the content should be archived. - /// - /// 204 => Content archived. - /// 404 => Content, schema or app not found. - /// 400 => Content was already archived. - /// - /// - /// You can read the generated documentation for your app at /api/content/{appName}/docs - /// - [MustBeAppEditor] - [HttpPut] - [Route("content/{app}/{name}/{id}/archive/")] - [ApiCosts(1)] - public async Task ArchiveContent(string app, string name, Guid id, string dueTime = null) - { - await contentQuery.FindSchemaAsync(App, name); - - var command = CreateCommand(id, Status.Archived, dueTime); - - await CommandBus.PublishAsync(command); - - return NoContent(); - } - - /// - /// Restore a content item. - /// - /// The name of the app. - /// The name of the schema. - /// The id of the content item to restore. - /// The date and time when the content should be restored. - /// - /// 204 => Content restored. - /// 404 => Content, schema or app not found. - /// 400 => Content was not archived. - /// - /// - /// You can read the generated documentation for your app at /api/content/{appName}/docs - /// - [MustBeAppEditor] - [HttpPut] - [Route("content/{app}/{name}/{id}/restore/")] - [ApiCosts(1)] - public async Task RestoreContent(string app, string name, Guid id, string dueTime = null) - { - await contentQuery.FindSchemaAsync(App, name); - - var command = CreateCommand(id, Status.Draft, dueTime); - - await CommandBus.PublishAsync(command); - - return NoContent(); - } - - /// - /// Delete a content item. - /// - /// The name of the app. - /// The name of the schema. - /// The id of the content item to delete. - /// - /// 204 => Content has been deleted. - /// 404 => Content, schema or app not found. - /// - /// - /// You can create an generated documentation for your app at /api/content/{appName}/docs - /// - [MustBeAppEditor] - [HttpDelete] - [Route("content/{app}/{name}/{id}/")] - [ApiCosts(1)] - public async Task DeleteContent(string app, string name, Guid id) - { - await contentQuery.FindSchemaAsync(App, name); - - var command = new DeleteContent { ContentId = id }; - - await CommandBus.PublishAsync(command); - - return NoContent(); - } - - private static ChangeContentStatus CreateCommand(Guid id, Status status, string dueTime) - { - Instant? dt = null; - - if (!string.IsNullOrWhiteSpace(dueTime)) - { - var parseResult = InstantPattern.General.Parse(dueTime); - - if (parseResult.Success) - { - dt = parseResult.Value; - } - } - - return new ChangeContentStatus { Status = status, ContentId = id, DueTime = dt }; - } - } -} diff --git a/src/Squidex/Areas/Api/Controllers/Content/Generator/SchemaSwaggerGenerator.cs b/src/Squidex/Areas/Api/Controllers/Content/Generator/SchemaSwaggerGenerator.cs deleted file mode 100644 index 03acbbdad..000000000 --- a/src/Squidex/Areas/Api/Controllers/Content/Generator/SchemaSwaggerGenerator.cs +++ /dev/null @@ -1,283 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Generic; -using System.Linq; -using NJsonSchema; -using NSwag; -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; - -namespace Squidex.Areas.Api.Controllers.Contents.Generator -{ - public sealed class SchemaSwaggerGenerator - { - private static readonly string SchemaQueryDescription; - private static readonly string SchemaBodyDescription; - private static readonly List EditorSecurity; - private static readonly List ReaderSecurity; - private readonly ContentSchemaBuilder schemaBuilder = new ContentSchemaBuilder(); - private readonly SwaggerDocument document; - private readonly JsonSchema4 contentSchema; - private readonly JsonSchema4 dataSchema; - private readonly string schemaPath; - private readonly string schemaName; - private readonly string schemaType; - private readonly string appPath; - - static SchemaSwaggerGenerator() - { - SchemaBodyDescription = SwaggerHelper.LoadDocs("schemabody"); - SchemaQueryDescription = SwaggerHelper.LoadDocs("schemaquery"); - - ReaderSecurity = new List - { - new SwaggerSecurityRequirement - { - { - Constants.SecurityDefinition, new[] { SquidexRoles.AppReader } - } - } - }; - - EditorSecurity = new List - { - new SwaggerSecurityRequirement - { - { - Constants.SecurityDefinition, new[] { SquidexRoles.AppEditor } - } - } - }; - } - - public SchemaSwaggerGenerator(SwaggerDocument document, string path, Schema schema, Func schemaResolver, PartitionResolver partitionResolver) - { - this.document = document; - - appPath = path; - - schemaPath = schema.Name; - schemaName = schema.DisplayName(); - schemaType = schema.TypeName(); - - dataSchema = schemaResolver($"{schemaType}Dto", schema.BuildJsonSchema(partitionResolver, schemaResolver)); - - contentSchema = schemaResolver($"{schemaType}ContentDto", schemaBuilder.CreateContentSchema(schema, dataSchema)); - } - - public void GenerateSchemaOperations() - { - document.Tags.Add( - new SwaggerTag - { - Name = schemaName, Description = $"API to managed {schemaName} contents." - }); - - var schemaOperations = new List - { - GenerateSchemaQueryOperation(), - GenerateSchemaCreateOperation(), - GenerateSchemaGetOperation(), - GenerateSchemaUpdateOperation(), - GenerateSchemaPatchOperation(), - GenerateSchemaPublishOperation(), - GenerateSchemaUnpublishOperation(), - GenerateSchemaArchiveOperation(), - GenerateSchemaRestoreOperation(), - GenerateSchemaDeleteOperation() - }; - - foreach (var operation in schemaOperations.SelectMany(x => x.Values).Distinct()) - { - operation.Tags = new List { schemaName }; - } - } - - private SwaggerOperations GenerateSchemaQueryOperation() - { - return AddOperation(SwaggerOperationMethod.Get, null, $"{appPath}/{schemaPath}", operation => - { - 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 (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."); - operation.AddQueryParameter("orderby", JsonObjectType.String, "Optional OData order definition."); - - operation.AddResponse("200", $"{schemaName} content retrieved.", CreateContentsSchema(schemaName, contentSchema)); - }); - } - - private SwaggerOperations GenerateSchemaGetOperation() - { - return AddOperation(SwaggerOperationMethod.Get, schemaName, $"{appPath}/{schemaPath}/{{id}}", operation => - { - operation.OperationId = $"Get{schemaType}Content"; - operation.Summary = $"Get a {schemaName} content."; - operation.Security = ReaderSecurity; - - operation.AddResponse("200", $"{schemaName} content found.", contentSchema); - }); - } - - private SwaggerOperations GenerateSchemaCreateOperation() - { - return AddOperation(SwaggerOperationMethod.Post, null, $"{appPath}/{schemaPath}", operation => - { - operation.OperationId = $"Create{schemaType}Content"; - operation.Summary = $"Create a {schemaName} content."; - operation.Security = EditorSecurity; - - operation.AddBodyParameter("data", dataSchema, SchemaBodyDescription); - operation.AddQueryParameter("publish", JsonObjectType.Boolean, "Set to true to autopublish content."); - - operation.AddResponse("201", $"{schemaName} content created.", contentSchema); - }); - } - - private SwaggerOperations GenerateSchemaUpdateOperation() - { - return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appPath}/{schemaPath}/{{id}}", operation => - { - operation.OperationId = $"Update{schemaType}Content"; - operation.Summary = $"Update a {schemaName} content."; - operation.Security = EditorSecurity; - - operation.AddBodyParameter("data", dataSchema, SchemaBodyDescription); - - operation.AddResponse("200", $"{schemaName} content updated.", dataSchema); - }); - } - - private SwaggerOperations GenerateSchemaPatchOperation() - { - return AddOperation(SwaggerOperationMethod.Patch, schemaName, $"{appPath}/{schemaPath}/{{id}}", operation => - { - operation.OperationId = $"Path{schemaType}Content"; - operation.Summary = $"Patch a {schemaName} content."; - operation.Security = EditorSecurity; - - operation.AddBodyParameter("data", dataSchema, SchemaBodyDescription); - - operation.AddResponse("200", $"{schemaName} content patched.", dataSchema); - }); - } - - private SwaggerOperations GenerateSchemaPublishOperation() - { - return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appPath}/{schemaPath}/{{id}}/publish", operation => - { - operation.OperationId = $"Publish{schemaType}Content"; - operation.Summary = $"Publish a {schemaName} content."; - operation.Security = EditorSecurity; - - operation.AddResponse("204", $"{schemaName} content published."); - }); - } - - private SwaggerOperations GenerateSchemaUnpublishOperation() - { - return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appPath}/{schemaPath}/{{id}}/unpublish", operation => - { - operation.OperationId = $"Unpublish{schemaType}Content"; - operation.Summary = $"Unpublish a {schemaName} content."; - operation.Security = EditorSecurity; - - operation.AddResponse("204", $"{schemaName} content unpublished."); - }); - } - - private SwaggerOperations GenerateSchemaArchiveOperation() - { - return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appPath}/{schemaPath}/{{id}}/archive", operation => - { - operation.OperationId = $"Archive{schemaType}Content"; - operation.Summary = $"Archive a {schemaName} content."; - operation.Security = EditorSecurity; - - operation.AddResponse("204", $"{schemaName} content restored."); - }); - } - - private SwaggerOperations GenerateSchemaRestoreOperation() - { - return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appPath}/{schemaPath}/{{id}}/restore", operation => - { - operation.OperationId = $"Restore{schemaType}Content"; - operation.Summary = $"Restore a {schemaName} content."; - operation.Security = EditorSecurity; - - operation.AddResponse("204", $"{schemaName} content restored."); - }); - } - - private SwaggerOperations GenerateSchemaDeleteOperation() - { - return AddOperation(SwaggerOperationMethod.Delete, schemaName, $"{appPath}/{schemaPath}/{{id}}/", operation => - { - operation.OperationId = $"Delete{schemaType}Content"; - operation.Summary = $"Delete a {schemaName} content."; - operation.Security = EditorSecurity; - - operation.AddResponse("204", $"{schemaName} content deleted."); - }); - } - - private SwaggerOperations AddOperation(SwaggerOperationMethod method, string entityName, string path, Action updater) - { - var operations = document.Paths.GetOrAdd(path, k => new SwaggerOperations()); - var operation = new SwaggerOperation(); - - updater(operation); - - operations[method] = operation; - - if (entityName != null) - { - operation.AddPathParameter("id", JsonObjectType.String, $"The id of the {entityName} content (GUID)."); - - operation.AddResponse("404", $"App, schema or {entityName} content not found."); - } - - return operations; - } - - private static JsonSchema4 CreateContentsSchema(string schemaName, JsonSchema4 contentSchema) - { - var schema = new JsonSchema4 - { - Properties = - { - ["total"] = new JsonProperty - { - Type = JsonObjectType.Number, IsRequired = true, Description = $"The total number of {schemaName} contents." - }, - ["items"] = new JsonProperty - { - Type = JsonObjectType.Array, IsRequired = true, Item = contentSchema, Description = $"The {schemaName} contents." - } - }, - Type = JsonObjectType.Object - }; - - return schema; - } - } -} \ No newline at end of file diff --git a/src/Squidex/Areas/Api/Controllers/Content/Generator/SchemasSwaggerGenerator.cs b/src/Squidex/Areas/Api/Controllers/Content/Generator/SchemasSwaggerGenerator.cs deleted file mode 100644 index c88f8cad2..000000000 --- a/src/Squidex/Areas/Api/Controllers/Content/Generator/SchemasSwaggerGenerator.cs +++ /dev/null @@ -1,84 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Options; -using NJsonSchema; -using NSwag; -using NSwag.AspNetCore; -using NSwag.SwaggerGeneration; -using Squidex.Config; -using Squidex.Domain.Apps.Entities.Apps; -using Squidex.Domain.Apps.Entities.Schemas; -using Squidex.Infrastructure; -using Squidex.Pipeline.Swagger; - -namespace Squidex.Areas.Api.Controllers.Contents.Generator -{ - public sealed class SchemasSwaggerGenerator - { - private readonly HttpContext context; - private readonly SwaggerSettings settings; - private readonly MyUrlsOptions urlOptions; - private SwaggerJsonSchemaGenerator schemaGenerator; - private JsonSchemaResolver schemaResolver; - private SwaggerDocument document; - - public SchemasSwaggerGenerator(IHttpContextAccessor context, SwaggerSettings settings, IOptions urlOptions) - { - this.context = context.HttpContext; - this.settings = settings; - this.urlOptions = urlOptions.Value; - } - - public async Task Generate(IAppEntity app, IEnumerable schemas) - { - document = SwaggerHelper.CreateApiDocument(context, urlOptions, app.Name); - - schemaGenerator = new SwaggerJsonSchemaGenerator(settings); - schemaResolver = new SwaggerSchemaResolver(document, settings); - - GenerateSchemasOperations(schemas, app); - - await GenerateDefaultErrorsAsync(); - - return document; - } - - private void GenerateSchemasOperations(IEnumerable schemas, IAppEntity app) - { - var appBasePath = $"/content/{app.Name}"; - - foreach (var schema in schemas.Where(x => x.IsPublished).Select(x => x.SchemaDef)) - { - new SchemaSwaggerGenerator(document, appBasePath, schema, AppendSchema, app.PartitionResolver()).GenerateSchemaOperations(); - } - } - - private async Task GenerateDefaultErrorsAsync() - { - const string errorDescription = "Operation failed with internal server error."; - - var errorDtoSchema = await schemaGenerator.GetErrorDtoSchemaAsync(schemaResolver); - - foreach (var operation in document.Paths.Values.SelectMany(x => x.Values)) - { - operation.Responses.Add("500", new SwaggerResponse { Description = errorDescription, Schema = errorDtoSchema }); - } - } - - private JsonSchema4 AppendSchema(string name, JsonSchema4 schema) - { - name = char.ToUpperInvariant(name[0]) + name.Substring(1); - - return new JsonSchema4 { Reference = document.Definitions.GetOrAdd(name, x => schema) }; - } - } -} diff --git a/src/Squidex/Areas/Api/Controllers/Content/Models/ContentDto.cs b/src/Squidex/Areas/Api/Controllers/Content/Models/ContentDto.cs deleted file mode 100644 index 2be029606..000000000 --- a/src/Squidex/Areas/Api/Controllers/Content/Models/ContentDto.cs +++ /dev/null @@ -1,97 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.ComponentModel.DataAnnotations; -using NodaTime; -using Squidex.Domain.Apps.Core.Contents; -using Squidex.Domain.Apps.Entities.Contents.Commands; -using Squidex.Infrastructure; -using Squidex.Infrastructure.Commands; - -namespace Squidex.Areas.Api.Controllers.Contents.Models -{ - public sealed class ContentDto - { - /// - /// The if of the content item. - /// - public Guid Id { get; set; } - - /// - /// The user that has created the content item. - /// - [Required] - public RefToken CreatedBy { get; set; } - - /// - /// The user that has updated the content item. - /// - [Required] - public RefToken LastModifiedBy { get; set; } - - /// - /// The data of the content item. - /// - [Required] - public object Data { get; set; } - - /// - /// The scheduled status. - /// - public Status? ScheduledTo { get; set; } - - /// - /// The scheduled date. - /// - public Instant? ScheduledAt { get; set; } - - /// - /// The user that has scheduled the content. - /// - public RefToken ScheduledBy { get; set; } - - /// - /// The date and time when the content item has been created. - /// - public Instant Created { get; set; } - - /// - /// The date and time when the content item has been modified last. - /// - public Instant LastModified { get; set; } - - /// - /// Gets the status of the content. - /// - public Status Status { get; set; } - - /// - /// The version of the content. - /// - public long Version { get; set; } - - public static ContentDto Create(CreateContent command, EntityCreatedResult result) - { - var now = SystemClock.Instance.GetCurrentInstant(); - - var response = new ContentDto - { - 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; - } - } -} diff --git a/src/Squidex/Areas/Api/Controllers/Content/ContentSwaggerController.cs b/src/Squidex/Areas/Api/Controllers/Contents/ContentSwaggerController.cs similarity index 100% rename from src/Squidex/Areas/Api/Controllers/Content/ContentSwaggerController.cs rename to src/Squidex/Areas/Api/Controllers/Contents/ContentSwaggerController.cs diff --git a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs new file mode 100644 index 000000000..063f17ea1 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs @@ -0,0 +1,522 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using NodaTime; +using NodaTime.Text; +using NSwag.Annotations; +using Squidex.Areas.Api.Controllers.Contents.Models; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Entities; +using Squidex.Domain.Apps.Entities.Contents; +using Squidex.Domain.Apps.Entities.Contents.Commands; +using Squidex.Domain.Apps.Entities.Contents.GraphQL; +using Squidex.Infrastructure.Commands; +using Squidex.Pipeline; + +namespace Squidex.Areas.Api.Controllers.Contents +{ + [ApiAuthorize] + [ApiExceptionFilter] + [AppApi] + [SwaggerIgnore] + public sealed class ContentsController : ApiController + { + private readonly IOptions controllerOptions; + private readonly IContentQueryService contentQuery; + private readonly IGraphQLService graphQl; + + public ContentsController(ICommandBus commandBus, + IContentQueryService contentQuery, + IGraphQLService graphQl, + IOptions controllerOptions) + : base(commandBus) + { + this.contentQuery = contentQuery; + this.controllerOptions = controllerOptions; + + this.graphQl = graphQl; + } + + /// + /// GraphQL endpoint. + /// + /// The name of the app. + /// The graphql query. + /// + /// 200 => Contents retrieved or mutated. + /// 404 => Schema or app not found. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs + /// + [MustBeAppReader] + [HttpGet] + [HttpPost] + [Route("content/{app}/graphql/")] + [ApiCosts(2)] + public async Task PostGraphQL(string app, [FromBody] GraphQLQuery query) + { + var result = await graphQl.QueryAsync(Context().Base, query); + + if (result.HasError) + { + return BadRequest(result.Response); + } + else + { + return Ok(result.Response); + } + } + + /// + /// GraphQL endpoint with batch support. + /// + /// The name of the app. + /// The graphql queries. + /// + /// 200 => Contents retrieved or mutated. + /// 404 => Schema or app not found. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs + /// + [MustBeAppReader] + [HttpGet] + [HttpPost] + [Route("content/{app}/graphql/batch")] + [ApiCosts(2)] + public async Task PostGraphQLBatch(string app, [FromBody] GraphQLQuery[] batch) + { + var result = await graphQl.QueryAsync(Context().Base, batch); + + if (result.HasError) + { + return BadRequest(result.Response); + } + else + { + return Ok(result.Response); + } + } + + /// + /// Queries contents. + /// + /// The name of the app. + /// The name of the schema. + /// The optional ids of the content to fetch. + /// Indicates whether to query content items from the archive. + /// + /// 200 => Contents retrieved. + /// 404 => Schema or app not found. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs + /// + [MustBeAppReader] + [HttpGet] + [Route("content/{app}/{name}/")] + [ApiCosts(2)] + public async Task GetContents(string app, string name, [FromQuery] bool archived = false, [FromQuery] string ids = null) + { + var context = Context().WithArchived(archived).WithSchemaName(name); + + var result = await contentQuery.QueryAsync(context, Q.Empty.WithIds(ids).WithODataQuery(Request.QueryString.ToString())); + + var response = new ContentsDto + { + Total = result.Total, + Items = result.Take(200).Select(x => ContentDto.FromContent(x, context.Base)).ToArray() + }; + + if (controllerOptions.Value.EnableSurrogateKeys && response.Items.Length <= controllerOptions.Value.MaxItemsForSurrogateKeys) + { + Response.Headers["Surrogate-Key"] = response.Items.ToSurrogateKeys(); + } + + Response.Headers["ETag"] = response.Items.ToManyEtag(response.Total); + + return Ok(response); + } + + /// + /// Get a content item. + /// + /// The name of the app. + /// The name of the schema. + /// The id of the content to fetch. + /// + /// 200 => Content found. + /// 404 => Content, schema or app not found. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs + /// + [MustBeAppReader] + [HttpGet] + [Route("content/{app}/{name}/{id}/")] + [ApiCosts(1)] + public async Task GetContent(string app, string name, Guid id) + { + var context = Context().WithSchemaName(name); + var content = await contentQuery.FindContentAsync(context, id); + + var response = ContentDto.FromContent(content, context.Base); + + if (controllerOptions.Value.EnableSurrogateKeys) + { + Response.Headers["Surrogate-Key"] = content.Id.ToString(); + } + + Response.Headers["ETag"] = content.Version.ToString(); + + return Ok(response); + } + + /// + /// Get a content item with a specific version. + /// + /// The name of the app. + /// The name of the schema. + /// The id of the content to fetch. + /// The version fo the content to fetch. + /// + /// 200 => Content found. + /// 404 => Content, schema or app not found. + /// 400 => Content data is not valid. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs + /// + [MustBeAppReader] + [HttpGet] + [Route("content/{app}/{name}/{id}/{version}/")] + [ApiCosts(1)] + public async Task GetContentVersion(string app, string name, Guid id, int version) + { + var context = Context().WithSchemaName(name); + var content = await contentQuery.FindContentAsync(context, id, version); + + var response = ContentDto.FromContent(content, context.Base); + + if (controllerOptions.Value.EnableSurrogateKeys) + { + Response.Headers["Surrogate-Key"] = content.Id.ToString(); + } + + Response.Headers["ETag"] = content.Version.ToString(); + + return Ok(response.Data); + } + + /// + /// Create a content item. + /// + /// The name of the app. + /// The name of the schema. + /// The full data for the content item. + /// Indicates whether the content should be published immediately. + /// + /// 201 => Content created. + /// 404 => Content, schema or app not found. + /// 400 => Content data is not valid. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs + /// + [MustBeAppEditor] + [HttpPost] + [Route("content/{app}/{name}/")] + [ApiCosts(1)] + public async Task PostContent(string app, string name, [FromBody] NamedContentData request, [FromQuery] bool publish = false) + { + await contentQuery.ThrowIfSchemaNotExistsAsync(Context().WithSchemaName(name)); + + var command = new CreateContent { ContentId = Guid.NewGuid(), Data = request.ToCleaned(), Publish = publish }; + + var context = await CommandBus.PublishAsync(command); + + var result = context.Result>(); + var response = ContentDto.FromCommand(command, result); + + return CreatedAtAction(nameof(GetContent), new { id = command.ContentId }, response); + } + + /// + /// Update a content item. + /// + /// The name of the app. + /// The name of the schema. + /// The id of the content item to update. + /// The full data for the content item. + /// Indicates whether the update is a proposal. + /// + /// 200 => Content updated. + /// 404 => Content, schema or app not found. + /// 400 => Content data is not valid. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs + /// + [MustBeAppEditor] + [HttpPut] + [Route("content/{app}/{name}/{id}/")] + [ApiCosts(1)] + public async Task PutContent(string app, string name, Guid id, [FromBody] NamedContentData request, [FromQuery] bool asDraft = false) + { + await contentQuery.ThrowIfSchemaNotExistsAsync(Context().WithSchemaName(name)); + + var command = new UpdateContent { ContentId = id, Data = request.ToCleaned(), AsDraft = asDraft }; + var context = await CommandBus.PublishAsync(command); + + var result = context.Result(); + var response = result.Data; + + return Ok(response); + } + + /// + /// Patchs a content item. + /// + /// The name of the app. + /// The name of the schema. + /// The id of the content item to patch. + /// The patch for the content item. + /// Indicates whether the patch is a proposal. + /// + /// 200 => Content patched. + /// 404 => Content, schema or app not found. + /// 400 => Content patch is not valid. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs + /// + [MustBeAppEditor] + [HttpPatch] + [Route("content/{app}/{name}/{id}/")] + [ApiCosts(1)] + public async Task PatchContent(string app, string name, Guid id, [FromBody] NamedContentData request, [FromQuery] bool asDraft = false) + { + await contentQuery.ThrowIfSchemaNotExistsAsync(Context().WithSchemaName(name)); + + var command = new PatchContent { ContentId = id, Data = request.ToCleaned(), AsDraft = asDraft }; + var context = await CommandBus.PublishAsync(command); + + var result = context.Result(); + var response = result.Data; + + return Ok(response); + } + + /// + /// Publish a content item. + /// + /// The name of the app. + /// The name of the schema. + /// The id of the content item to publish. + /// The date and time when the content should be published. + /// + /// 204 => Content published. + /// 404 => Content, schema or app not found. + /// 400 => Content was already published. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs + /// + [MustBeAppEditor] + [HttpPut] + [Route("content/{app}/{name}/{id}/publish/")] + [ApiCosts(1)] + public async Task PublishContent(string app, string name, Guid id, string dueTime = null) + { + await contentQuery.ThrowIfSchemaNotExistsAsync(Context().WithSchemaName(name)); + + var command = CreateCommand(id, Status.Published, dueTime); + + await CommandBus.PublishAsync(command); + + return NoContent(); + } + + /// + /// Unpublish a content item. + /// + /// The name of the app. + /// The name of the schema. + /// The id of the content item to unpublish. + /// The date and time when the content should be unpublished. + /// + /// 204 => Content unpublished. + /// 404 => Content, schema or app not found. + /// 400 => Content was not published. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs + /// + [MustBeAppEditor] + [HttpPut] + [Route("content/{app}/{name}/{id}/unpublish/")] + [ApiCosts(1)] + public async Task UnpublishContent(string app, string name, Guid id, string dueTime = null) + { + await contentQuery.ThrowIfSchemaNotExistsAsync(Context().WithSchemaName(name)); + + var command = CreateCommand(id, Status.Draft, dueTime); + + await CommandBus.PublishAsync(command); + + return NoContent(); + } + + /// + /// Archive a content item. + /// + /// The name of the app. + /// The name of the schema. + /// The id of the content item to archive. + /// The date and time when the content should be archived. + /// + /// 204 => Content archived. + /// 404 => Content, schema or app not found. + /// 400 => Content was already archived. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs + /// + [MustBeAppEditor] + [HttpPut] + [Route("content/{app}/{name}/{id}/archive/")] + [ApiCosts(1)] + public async Task ArchiveContent(string app, string name, Guid id, string dueTime = null) + { + await contentQuery.ThrowIfSchemaNotExistsAsync(Context().WithSchemaName(name)); + + var command = CreateCommand(id, Status.Archived, dueTime); + + await CommandBus.PublishAsync(command); + + return NoContent(); + } + + /// + /// Restore a content item. + /// + /// The name of the app. + /// The name of the schema. + /// The id of the content item to restore. + /// The date and time when the content should be restored. + /// + /// 204 => Content restored. + /// 404 => Content, schema or app not found. + /// 400 => Content was not archived. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs + /// + [MustBeAppEditor] + [HttpPut] + [Route("content/{app}/{name}/{id}/restore/")] + [ApiCosts(1)] + public async Task RestoreContent(string app, string name, Guid id, string dueTime = null) + { + await contentQuery.ThrowIfSchemaNotExistsAsync(Context().WithSchemaName(name)); + + var command = CreateCommand(id, Status.Draft, dueTime); + + await CommandBus.PublishAsync(command); + + return NoContent(); + } + + /// + /// Discard changes of a content item. + /// + /// The name of the app. + /// The name of the schema. + /// The id of the content item to discard changes. + /// + /// 204 => Content restored. + /// 404 => Content, schema or app not found. + /// 400 => Content was not archived. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs + /// + [MustBeAppEditor] + [HttpPut] + [Route("content/{app}/{name}/{id}/discard/")] + [ApiCosts(1)] + public async Task DiscardChanges(string app, string name, Guid id) + { + await contentQuery.ThrowIfSchemaNotExistsAsync(Context().WithSchemaName(name)); + + var command = new DiscardChanges { ContentId = id }; + + await CommandBus.PublishAsync(command); + + return NoContent(); + } + + /// + /// Delete a content item. + /// + /// The name of the app. + /// The name of the schema. + /// The id of the content item to delete. + /// + /// 204 => Content has been deleted. + /// 404 => Content, schema or app not found. + /// + /// + /// You can create an generated documentation for your app at /api/content/{appName}/docs + /// + [MustBeAppEditor] + [HttpDelete] + [Route("content/{app}/{name}/{id}/")] + [ApiCosts(1)] + public async Task DeleteContent(string app, string name, Guid id) + { + await contentQuery.ThrowIfSchemaNotExistsAsync(Context().WithSchemaName(name)); + + var command = new DeleteContent { ContentId = id }; + + await CommandBus.PublishAsync(command); + + return NoContent(); + } + + private static ChangeContentStatus CreateCommand(Guid id, Status status, string dueTime) + { + Instant? dt = null; + + if (!string.IsNullOrWhiteSpace(dueTime)) + { + var parseResult = InstantPattern.General.Parse(dueTime); + + if (parseResult.Success) + { + dt = parseResult.Value; + } + } + + return new ChangeContentStatus { Status = status, ContentId = id, DueTime = dt }; + } + + private ContentQueryContext Context() + { + return new ContentQueryContext(QueryContext.Create(App, User) + .WithLanguages(Request.Headers["X-Languages"])) + .WithFlatten(Request.Headers.ContainsKey("X-Flatten")) + .WithUnpublished(Request.Headers.ContainsKey("X-Unpublished")); + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs b/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs new file mode 100644 index 000000000..4b0538caa --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs @@ -0,0 +1,283 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using NJsonSchema; +using NSwag; +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; + +namespace Squidex.Areas.Api.Controllers.Contents.Generator +{ + public sealed class SchemaSwaggerGenerator + { + private static readonly string SchemaQueryDescription; + private static readonly string SchemaBodyDescription; + private static readonly List EditorSecurity; + private static readonly List ReaderSecurity; + private readonly ContentSchemaBuilder schemaBuilder = new ContentSchemaBuilder(); + private readonly SwaggerDocument document; + private readonly JsonSchema4 contentSchema; + private readonly JsonSchema4 dataSchema; + private readonly string schemaPath; + private readonly string schemaName; + private readonly string schemaType; + private readonly string appPath; + + static SchemaSwaggerGenerator() + { + SchemaBodyDescription = SwaggerHelper.LoadDocs("schemabody"); + SchemaQueryDescription = SwaggerHelper.LoadDocs("schemaquery"); + + ReaderSecurity = new List + { + new SwaggerSecurityRequirement + { + { + Constants.SecurityDefinition, new[] { SquidexRoles.AppReader } + } + } + }; + + EditorSecurity = new List + { + new SwaggerSecurityRequirement + { + { + Constants.SecurityDefinition, new[] { SquidexRoles.AppEditor } + } + } + }; + } + + public SchemaSwaggerGenerator(SwaggerDocument document, string path, Schema schema, Func schemaResolver, PartitionResolver partitionResolver) + { + this.document = document; + + appPath = path; + + schemaPath = schema.Name; + schemaName = schema.DisplayName(); + schemaType = schema.TypeName(); + + dataSchema = schemaResolver($"{schemaType}Dto", schema.BuildJsonSchema(partitionResolver, schemaResolver)); + + contentSchema = schemaResolver($"{schemaType}ContentDto", schemaBuilder.CreateContentSchema(schema, dataSchema)); + } + + public void GenerateSchemaOperations() + { + document.Tags.Add( + new SwaggerTag + { + Name = schemaName, Description = $"API to managed {schemaName} contents." + }); + + var schemaOperations = new List + { + GenerateSchemaQueryOperation(), + GenerateSchemaCreateOperation(), + GenerateSchemaGetOperation(), + GenerateSchemaUpdateOperation(), + GenerateSchemaPatchOperation(), + GenerateSchemaPublishOperation(), + GenerateSchemaUnpublishOperation(), + GenerateSchemaArchiveOperation(), + GenerateSchemaRestoreOperation(), + GenerateSchemaDeleteOperation() + }; + + foreach (var operation in schemaOperations.SelectMany(x => x.Values).Distinct()) + { + operation.Tags = new List { schemaName }; + } + } + + private SwaggerPathItem GenerateSchemaQueryOperation() + { + return AddOperation(SwaggerOperationMethod.Get, null, $"{appPath}/{schemaPath}", operation => + { + 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 (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."); + operation.AddQueryParameter("orderby", JsonObjectType.String, "Optional OData order definition."); + + operation.AddResponse("200", $"{schemaName} content retrieved.", CreateContentsSchema(schemaName, contentSchema)); + }); + } + + private SwaggerPathItem GenerateSchemaGetOperation() + { + return AddOperation(SwaggerOperationMethod.Get, schemaName, $"{appPath}/{schemaPath}/{{id}}", operation => + { + operation.OperationId = $"Get{schemaType}Content"; + operation.Summary = $"Get a {schemaName} content."; + operation.Security = ReaderSecurity; + + operation.AddResponse("200", $"{schemaName} content found.", contentSchema); + }); + } + + private SwaggerPathItem GenerateSchemaCreateOperation() + { + return AddOperation(SwaggerOperationMethod.Post, null, $"{appPath}/{schemaPath}", operation => + { + operation.OperationId = $"Create{schemaType}Content"; + operation.Summary = $"Create a {schemaName} content."; + operation.Security = EditorSecurity; + + operation.AddBodyParameter("data", dataSchema, SchemaBodyDescription); + operation.AddQueryParameter("publish", JsonObjectType.Boolean, "Set to true to autopublish content."); + + operation.AddResponse("201", $"{schemaName} content created.", contentSchema); + }); + } + + private SwaggerPathItem GenerateSchemaUpdateOperation() + { + return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appPath}/{schemaPath}/{{id}}", operation => + { + operation.OperationId = $"Update{schemaType}Content"; + operation.Summary = $"Update a {schemaName} content."; + operation.Security = EditorSecurity; + + operation.AddBodyParameter("data", dataSchema, SchemaBodyDescription); + + operation.AddResponse("200", $"{schemaName} content updated.", dataSchema); + }); + } + + private SwaggerPathItem GenerateSchemaPatchOperation() + { + return AddOperation(SwaggerOperationMethod.Patch, schemaName, $"{appPath}/{schemaPath}/{{id}}", operation => + { + operation.OperationId = $"Path{schemaType}Content"; + operation.Summary = $"Patch a {schemaName} content."; + operation.Security = EditorSecurity; + + operation.AddBodyParameter("data", dataSchema, SchemaBodyDescription); + + operation.AddResponse("200", $"{schemaName} content patched.", dataSchema); + }); + } + + private SwaggerPathItem GenerateSchemaPublishOperation() + { + return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appPath}/{schemaPath}/{{id}}/publish", operation => + { + operation.OperationId = $"Publish{schemaType}Content"; + operation.Summary = $"Publish a {schemaName} content."; + operation.Security = EditorSecurity; + + operation.AddResponse("204", $"{schemaName} content published."); + }); + } + + private SwaggerPathItem GenerateSchemaUnpublishOperation() + { + return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appPath}/{schemaPath}/{{id}}/unpublish", operation => + { + operation.OperationId = $"Unpublish{schemaType}Content"; + operation.Summary = $"Unpublish a {schemaName} content."; + operation.Security = EditorSecurity; + + operation.AddResponse("204", $"{schemaName} content unpublished."); + }); + } + + private SwaggerPathItem GenerateSchemaArchiveOperation() + { + return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appPath}/{schemaPath}/{{id}}/archive", operation => + { + operation.OperationId = $"Archive{schemaType}Content"; + operation.Summary = $"Archive a {schemaName} content."; + operation.Security = EditorSecurity; + + operation.AddResponse("204", $"{schemaName} content restored."); + }); + } + + private SwaggerPathItem GenerateSchemaRestoreOperation() + { + return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appPath}/{schemaPath}/{{id}}/restore", operation => + { + operation.OperationId = $"Restore{schemaType}Content"; + operation.Summary = $"Restore a {schemaName} content."; + operation.Security = EditorSecurity; + + operation.AddResponse("204", $"{schemaName} content restored."); + }); + } + + private SwaggerPathItem GenerateSchemaDeleteOperation() + { + return AddOperation(SwaggerOperationMethod.Delete, schemaName, $"{appPath}/{schemaPath}/{{id}}/", operation => + { + operation.OperationId = $"Delete{schemaType}Content"; + operation.Summary = $"Delete a {schemaName} content."; + operation.Security = EditorSecurity; + + operation.AddResponse("204", $"{schemaName} content deleted."); + }); + } + + private SwaggerPathItem AddOperation(SwaggerOperationMethod method, string entityName, string path, Action updater) + { + var operations = document.Paths.GetOrAddNew(path); + var operation = new SwaggerOperation(); + + updater(operation); + + operations[method] = operation; + + if (entityName != null) + { + operation.AddPathParameter("id", JsonObjectType.String, $"The id of the {entityName} content (GUID)."); + + operation.AddResponse("404", $"App, schema or {entityName} content not found."); + } + + return operations; + } + + private static JsonSchema4 CreateContentsSchema(string schemaName, JsonSchema4 contentSchema) + { + var schema = new JsonSchema4 + { + Properties = + { + ["total"] = new JsonProperty + { + Type = JsonObjectType.Number, IsRequired = true, Description = $"The total number of {schemaName} contents." + }, + ["items"] = new JsonProperty + { + Type = JsonObjectType.Array, IsRequired = true, Item = contentSchema, Description = $"The {schemaName} contents." + } + }, + Type = JsonObjectType.Object + }; + + return schema; + } + } +} \ No newline at end of file diff --git a/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasSwaggerGenerator.cs b/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasSwaggerGenerator.cs new file mode 100644 index 000000000..03d7120bd --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasSwaggerGenerator.cs @@ -0,0 +1,84 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using NJsonSchema; +using NSwag; +using NSwag.AspNetCore; +using NSwag.SwaggerGeneration; +using Squidex.Config; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; +using Squidex.Pipeline.Swagger; + +namespace Squidex.Areas.Api.Controllers.Contents.Generator +{ + public sealed class SchemasSwaggerGenerator + { + private readonly HttpContext context; + private readonly SwaggerSettings settings; + private readonly MyUrlsOptions urlOptions; + private SwaggerJsonSchemaGenerator schemaGenerator; + private JsonSchemaResolver schemaResolver; + private SwaggerDocument document; + + public SchemasSwaggerGenerator(IHttpContextAccessor context, SwaggerSettings settings, IOptions urlOptions) + { + this.context = context.HttpContext; + this.settings = settings; + this.urlOptions = urlOptions.Value; + } + + public async Task Generate(IAppEntity app, IEnumerable schemas) + { + document = SwaggerHelper.CreateApiDocument(context, urlOptions, app.Name); + + schemaGenerator = new SwaggerJsonSchemaGenerator(settings.GeneratorSettings); + schemaResolver = new SwaggerSchemaResolver(document, settings.GeneratorSettings); + + GenerateSchemasOperations(schemas, app); + + await GenerateDefaultErrorsAsync(); + + return document; + } + + private void GenerateSchemasOperations(IEnumerable schemas, IAppEntity app) + { + var appBasePath = $"/content/{app.Name}"; + + foreach (var schema in schemas.Where(x => x.IsPublished).Select(x => x.SchemaDef)) + { + new SchemaSwaggerGenerator(document, appBasePath, schema, AppendSchema, app.PartitionResolver()).GenerateSchemaOperations(); + } + } + + private async Task GenerateDefaultErrorsAsync() + { + const string errorDescription = "Operation failed with internal server error."; + + var errorDtoSchema = await schemaGenerator.GetErrorDtoSchemaAsync(schemaResolver); + + foreach (var operation in document.Paths.Values.SelectMany(x => x.Values)) + { + operation.Responses.Add("500", new SwaggerResponse { Description = errorDescription, Schema = errorDtoSchema }); + } + } + + private JsonSchema4 AppendSchema(string name, JsonSchema4 schema) + { + name = char.ToUpperInvariant(name[0]) + name.Substring(1); + + return new JsonSchema4 { Reference = document.Definitions.GetOrAdd(name, schema, (k, c) => c) }; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs b/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs new file mode 100644 index 000000000..6318e57ce --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs @@ -0,0 +1,125 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.ComponentModel.DataAnnotations; +using NodaTime; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.ConvertContent; +using Squidex.Domain.Apps.Entities; +using Squidex.Domain.Apps.Entities.Contents; +using Squidex.Domain.Apps.Entities.Contents.Commands; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Reflection; +using Squidex.Pipeline; + +namespace Squidex.Areas.Api.Controllers.Contents.Models +{ + public sealed class ContentDto : IGenerateEtag + { + /// + /// The if of the content item. + /// + public Guid Id { get; set; } + + /// + /// The user that has created the content item. + /// + [Required] + public RefToken CreatedBy { get; set; } + + /// + /// The user that has updated the content item. + /// + [Required] + public RefToken LastModifiedBy { get; set; } + + /// + /// The data of the content item. + /// + [Required] + public object Data { get; set; } + + /// + /// The pending changes of the content item. + /// + public object DataDraft { get; set; } + + /// + /// Indicates if the draft data is pending. + /// + public bool IsPending { get; set; } + + /// + /// The scheduled status. + /// + public ScheduleJobDto ScheduleJob { get; set; } + + /// + /// The date and time when the content item has been created. + /// + public Instant Created { get; set; } + + /// + /// The date and time when the content item has been modified last. + /// + public Instant LastModified { get; set; } + + /// + /// The the status of the content. + /// + public Status Status { get; set; } + + /// + /// The version of the content. + /// + public long Version { get; set; } + + public static ContentDto FromCommand(CreateContent command, EntityCreatedResult result) + { + var now = SystemClock.Instance.GetCurrentInstant(); + + var response = new ContentDto + { + 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; + } + + public static ContentDto FromContent(IContentEntity content, QueryContext context) + { + var response = SimpleMapper.Map(content, new ContentDto()); + + if (context.Flatten) + { + response.Data = content.Data?.ToFlatten(); + response.DataDraft = content.DataDraft?.ToFlatten(); + } + else + { + response.Data = content.Data; + response.DataDraft = content.DataDraft; + } + + if (content.ScheduleJob != null) + { + response.ScheduleJob = SimpleMapper.Map(content.ScheduleJob, new ScheduleJobDto()); + } + + return response; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Content/Models/ContentsDto.cs b/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs similarity index 100% rename from src/Squidex/Areas/Api/Controllers/Content/Models/ContentsDto.cs rename to src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs diff --git a/src/Squidex/Areas/Api/Controllers/Contents/Models/ScheduleJobDto.cs b/src/Squidex/Areas/Api/Controllers/Contents/Models/ScheduleJobDto.cs new file mode 100644 index 000000000..b0e7b34cb --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Contents/Models/ScheduleJobDto.cs @@ -0,0 +1,37 @@ +// ========================================================================== +// 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.Infrastructure; + +namespace Squidex.Areas.Api.Controllers.Contents.Models +{ + public sealed class ScheduleJobDto + { + /// + /// The id of the schedule job. + /// + public Guid Id { get; set; } + + /// + /// The new status. + /// + public Status Status { get; set; } + + /// + /// The user who schedule the content. + /// + public RefToken ScheduledBy { get; set; } + + /// + /// The target date and time when the content should be scheduled. + /// + public Instant DueTime { get; set; } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Contents/MyContentsControllerOptions.cs b/src/Squidex/Areas/Api/Controllers/Contents/MyContentsControllerOptions.cs new file mode 100644 index 000000000..bf0cd13af --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Contents/MyContentsControllerOptions.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Areas.Api.Controllers.Contents +{ + public sealed class MyContentsControllerOptions + { + public bool EnableSurrogateKeys { get; set; } + + public int MaxItemsForSurrogateKeys { get; set; } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/EventConsumers/EventConsumersController.cs b/src/Squidex/Areas/Api/Controllers/EventConsumers/EventConsumersController.cs index 41e232307..fd5abdeae 100644 --- a/src/Squidex/Areas/Api/Controllers/EventConsumers/EventConsumersController.cs +++ b/src/Squidex/Areas/Api/Controllers/EventConsumers/EventConsumersController.cs @@ -5,16 +5,15 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using NSwag.Annotations; +using Orleans; using Squidex.Areas.Api.Controllers.EventConsumers.Models; -using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.EventSourcing.Grains.Messages; -using Squidex.Infrastructure.Reflection; +using Squidex.Infrastructure.EventSourcing.Grains; +using Squidex.Infrastructure.Orleans; using Squidex.Pipeline; namespace Squidex.Areas.Api.Controllers.EventConsumers @@ -25,12 +24,12 @@ namespace Squidex.Areas.Api.Controllers.EventConsumers [SwaggerIgnore] public sealed class EventConsumersController : ApiController { - private readonly IPubSub pubSub; + private readonly IEventConsumerManagerGrain eventConsumerManagerGrain; - public EventConsumersController(ICommandBus commandBus, IPubSub pubSub) + public EventConsumersController(ICommandBus commandBus, IGrainFactory grainFactory) : base(commandBus) { - this.pubSub = pubSub; + eventConsumerManagerGrain = grainFactory.GetGrain(SingleGrain.Id); } [HttpGet] @@ -38,19 +37,19 @@ namespace Squidex.Areas.Api.Controllers.EventConsumers [ApiCosts(0)] public async Task GetEventConsumers() { - var entities = await pubSub.RequestAsync(new GetStatesRequest(), TimeSpan.FromSeconds(2), true); + var entities = await eventConsumerManagerGrain.GetConsumersAsync(); - var models = entities.States.Select(x => SimpleMapper.Map(x, new EventConsumerDto())).ToList(); + var response = entities.Value.Select(EventConsumerDto.FromEventConsumerInfo).ToList(); - return Ok(models); + return Ok(response); } [HttpPut] [Route("event-consumers/{name}/start/")] [ApiCosts(0)] - public IActionResult Start(string name) + public async Task Start(string name) { - pubSub.Publish(new StartConsumerMessage { ConsumerName = name }, true); + await eventConsumerManagerGrain.StartAsync(name); return NoContent(); } @@ -58,9 +57,9 @@ namespace Squidex.Areas.Api.Controllers.EventConsumers [HttpPut] [Route("event-consumers/{name}/stop/")] [ApiCosts(0)] - public IActionResult Stop(string name) + public async Task Stop(string name) { - pubSub.Publish(new StopConsumerMessage { ConsumerName = name }, true); + await eventConsumerManagerGrain.StopAsync(name); return NoContent(); } @@ -68,9 +67,9 @@ namespace Squidex.Areas.Api.Controllers.EventConsumers [HttpPut] [Route("event-consumers/{name}/reset/")] [ApiCosts(0)] - public IActionResult Reset(string name) + public async Task Reset(string name) { - pubSub.Publish(new ResetConsumerMessage { ConsumerName = name }, true); + await eventConsumerManagerGrain.ResetAsync(name); return NoContent(); } diff --git a/src/Squidex/Areas/Api/Controllers/EventConsumers/Models/EventConsumerDto.cs b/src/Squidex/Areas/Api/Controllers/EventConsumers/Models/EventConsumerDto.cs index 5dbb479b0..3ef6535c3 100644 --- a/src/Squidex/Areas/Api/Controllers/EventConsumers/Models/EventConsumerDto.cs +++ b/src/Squidex/Areas/Api/Controllers/EventConsumers/Models/EventConsumerDto.cs @@ -5,6 +5,9 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Reflection; + namespace Squidex.Areas.Api.Controllers.EventConsumers.Models { public sealed class EventConsumerDto @@ -18,5 +21,10 @@ namespace Squidex.Areas.Api.Controllers.EventConsumers.Models public string Error { get; set; } public string Position { get; set; } + + public static EventConsumerDto FromEventConsumerInfo(EventConsumerInfo eventConsumerInfo) + { + return SimpleMapper.Map(eventConsumerInfo, new EventConsumerDto()); + } } } diff --git a/src/Squidex/Areas/Api/Controllers/History/HistoryController.cs b/src/Squidex/Areas/Api/Controllers/History/HistoryController.cs index a80a8308f..c908e8888 100644 --- a/src/Squidex/Areas/Api/Controllers/History/HistoryController.cs +++ b/src/Squidex/Areas/Api/Controllers/History/HistoryController.cs @@ -12,7 +12,6 @@ using NSwag.Annotations; using Squidex.Areas.Api.Controllers.History.Models; using Squidex.Domain.Apps.Entities.History.Repositories; using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.Reflection; using Squidex.Pipeline; namespace Squidex.Areas.Api.Controllers.History @@ -52,7 +51,7 @@ namespace Squidex.Areas.Api.Controllers.History { var entities = await historyEventRepository.QueryByChannelAsync(App.Id, channel, 100); - var response = entities.Select(x => SimpleMapper.Map(x, new HistoryEventDto())).ToList(); + var response = entities.Select(HistoryEventDto.FromHistoryEvent).ToList(); return Ok(response); } diff --git a/src/Squidex/Areas/Api/Controllers/History/Models/HistoryEventDto.cs b/src/Squidex/Areas/Api/Controllers/History/Models/HistoryEventDto.cs index 39f8755be..5beb31654 100644 --- a/src/Squidex/Areas/Api/Controllers/History/Models/HistoryEventDto.cs +++ b/src/Squidex/Areas/Api/Controllers/History/Models/HistoryEventDto.cs @@ -8,6 +8,8 @@ using System; using System.ComponentModel.DataAnnotations; using NodaTime; +using Squidex.Domain.Apps.Entities.History; +using Squidex.Infrastructure.Reflection; namespace Squidex.Areas.Api.Controllers.History.Models { @@ -25,6 +27,12 @@ namespace Squidex.Areas.Api.Controllers.History.Models [Required] public string Actor { get; set; } + /// + /// The type of the event. + /// + [Required] + public string EventType { get; set; } + /// /// Gets a unique id for the event. /// @@ -39,5 +47,10 @@ namespace Squidex.Areas.Api.Controllers.History.Models /// The version identifier. /// public long Version { get; set; } + + public static HistoryEventDto FromHistoryEvent(IHistoryEventEntity x) + { + return SimpleMapper.Map(x, new HistoryEventDto()); + } } } diff --git a/src/Squidex/Areas/Api/Controllers/JsonInheritanceConverter.cs b/src/Squidex/Areas/Api/Controllers/JsonInheritanceConverter.cs index af9b07000..b21fcef56 100644 --- a/src/Squidex/Areas/Api/Controllers/JsonInheritanceConverter.cs +++ b/src/Squidex/Areas/Api/Controllers/JsonInheritanceConverter.cs @@ -17,11 +17,11 @@ using NJsonSchema.Annotations; namespace Squidex.Areas.Api.Controllers { - public sealed class JsonInheritanceConverter : JsonConverter + public class JsonInheritanceConverter : JsonConverter { private readonly string discriminator; - private readonly Dictionary mapNameToType = new Dictionary(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary mapTypeToName = new Dictionary(); + private readonly Dictionary mapTypeToName = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary mapNameToType = new Dictionary(); [ThreadStatic] private static bool IsReading; @@ -29,6 +29,11 @@ namespace Squidex.Areas.Api.Controllers [ThreadStatic] private static bool IsWriting; + public string DiscriminatorName + { + get { return discriminator; } + } + public override bool CanWrite { get @@ -56,15 +61,31 @@ namespace Squidex.Areas.Api.Controllers } public JsonInheritanceConverter(string discriminator, Type baseType) + : this(discriminator, baseType, null) + { + } + + protected JsonInheritanceConverter(string discriminator, Type baseType, IReadOnlyDictionary subTypes = null) { this.discriminator = discriminator; - foreach (var type in baseType.Assembly.GetTypes().Where(x => x != baseType && baseType.IsAssignableFrom(x))) + if (subTypes != null) { - var name = type.GetTypeInfo().GetCustomAttribute()?.Name ?? type.Name; + foreach (var subType in subTypes) + { + mapNameToType[subType.Value] = subType.Key; + mapTypeToName[subType.Key] = subType.Value; + } + } + else + { + foreach (var type in baseType.Assembly.GetTypes().Where(x => x != baseType && baseType.IsAssignableFrom(x))) + { + var name = type.GetTypeInfo().GetCustomAttribute()?.Name ?? type.Name; - mapTypeToName[type] = name; - mapNameToType[name] = type; + mapNameToType[type] = name; + mapTypeToName[name] = type; + } } } @@ -80,7 +101,7 @@ namespace Squidex.Areas.Api.Controllers { var jsonObject = JObject.FromObject(value, serializer); - jsonObject.AddFirst(new JProperty(discriminator, mapTypeToName[value.GetType()])); + jsonObject.AddFirst(new JProperty(discriminator, mapNameToType[value.GetType()])); writer.WriteToken(jsonObject.CreateReader()); } @@ -104,7 +125,7 @@ namespace Squidex.Areas.Api.Controllers return null; } - if (subName == null || !mapNameToType.TryGetValue(subName, out var subType)) + if (!mapTypeToName.TryGetValue(subName, out var subType)) { throw new InvalidOperationException($"Could not find subtype of '{objectType.Name}' with discriminator '{subName}'."); } diff --git a/src/Squidex/Areas/Api/Controllers/LanguageDto.cs b/src/Squidex/Areas/Api/Controllers/LanguageDto.cs index 10e02e773..4d4c369fa 100644 --- a/src/Squidex/Areas/Api/Controllers/LanguageDto.cs +++ b/src/Squidex/Areas/Api/Controllers/LanguageDto.cs @@ -6,6 +6,8 @@ // ========================================================================== using System.ComponentModel.DataAnnotations; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Reflection; namespace Squidex.Areas.Api.Controllers { @@ -22,5 +24,10 @@ namespace Squidex.Areas.Api.Controllers /// [Required] public string EnglishName { get; set; } + + public static LanguageDto FromLanguage(Language language) + { + return SimpleMapper.Map(language, new LanguageDto()); + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Languages/LanguagesController.cs b/src/Squidex/Areas/Api/Controllers/Languages/LanguagesController.cs index bf636ab99..e36ef7dc0 100644 --- a/src/Squidex/Areas/Api/Controllers/Languages/LanguagesController.cs +++ b/src/Squidex/Areas/Api/Controllers/Languages/LanguagesController.cs @@ -10,7 +10,6 @@ using Microsoft.AspNetCore.Mvc; using NSwag.Annotations; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.Reflection; using Squidex.Pipeline; namespace Squidex.Areas.Api.Controllers.Languages @@ -43,7 +42,7 @@ namespace Squidex.Areas.Api.Controllers.Languages [ApiCosts(0)] public IActionResult GetLanguages() { - var response = Language.AllLanguages.Select(x => SimpleMapper.Map(x, new LanguageDto())).ToList(); + var response = Language.AllLanguages.Select(LanguageDto.FromLanguage).ToList(); return Ok(response); } diff --git a/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs b/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs index b0d406c9b..2fc575f5e 100644 --- a/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs +++ b/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs @@ -5,15 +5,12 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using NSwag.Annotations; using Squidex.Areas.Api.Controllers.Plans.Models; -using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Services; using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.Reflection; using Squidex.Pipeline; namespace Squidex.Areas.Api.Controllers.Plans @@ -54,15 +51,9 @@ namespace Squidex.Areas.Api.Controllers.Plans [ApiCosts(0)] public IActionResult GetPlans(string app) { - var planId = appPlansProvider.GetPlanForApp(App).Id; + var hasPortal = appPlansBillingManager.HasPortal; - var response = new AppPlansDto - { - CurrentPlanId = planId, - Plans = appPlansProvider.GetAvailablePlans().Select(x => SimpleMapper.Map(x, new PlanDto())).ToList(), - PlanOwner = App.Plan?.Owner.Identifier, - HasPortal = appPlansBillingManager.HasPortal - }; + var response = AppPlansDto.FromApp(App, appPlansProvider, hasPortal); Response.Headers["ETag"] = App.Version.ToString(); @@ -88,8 +79,9 @@ namespace Squidex.Areas.Api.Controllers.Plans [ApiCosts(0)] public async Task ChangePlanAsync(string app, [FromBody] ChangePlanDto request) { - var redirectUri = (string)null; - var context = await CommandBus.PublishAsync(SimpleMapper.Map(request, new ChangePlan())); + var context = await CommandBus.PublishAsync(request.ToCommand()); + + string redirectUri = null; if (context.Result() is RedirectToCheckoutResult result) { diff --git a/src/Squidex/Areas/Api/Controllers/Plans/Models/AppPlansDto.cs b/src/Squidex/Areas/Api/Controllers/Plans/Models/AppPlansDto.cs index 8aff5f50a..f6130ceef 100644 --- a/src/Squidex/Areas/Api/Controllers/Plans/Models/AppPlansDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Plans/Models/AppPlansDto.cs @@ -6,6 +6,10 @@ // ========================================================================== using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Apps.Services; namespace Squidex.Areas.Api.Controllers.Plans.Models { @@ -14,6 +18,7 @@ namespace Squidex.Areas.Api.Controllers.Plans.Models /// /// The available plans. /// + [Required] public List Plans { get; set; } /// @@ -30,5 +35,20 @@ namespace Squidex.Areas.Api.Controllers.Plans.Models /// Indicates if there is a billing portal. /// public bool HasPortal { get; set; } + + public static AppPlansDto FromApp(IAppEntity app, IAppPlansProvider plans, bool hasPortal) + { + var planId = plans.GetPlanForApp(app).Id; + + var response = new AppPlansDto + { + CurrentPlanId = planId, + Plans = plans.GetAvailablePlans().Select(PlanDto.FromPlan).ToList(), + PlanOwner = app.Plan?.Owner.Identifier, + HasPortal = hasPortal + }; + + return response; + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Plans/Models/ChangePlanDto.cs b/src/Squidex/Areas/Api/Controllers/Plans/Models/ChangePlanDto.cs index e7adc9044..b671477cf 100644 --- a/src/Squidex/Areas/Api/Controllers/Plans/Models/ChangePlanDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Plans/Models/ChangePlanDto.cs @@ -6,6 +6,8 @@ // ========================================================================== using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Infrastructure.Reflection; namespace Squidex.Areas.Api.Controllers.Plans.Models { @@ -16,5 +18,10 @@ namespace Squidex.Areas.Api.Controllers.Plans.Models /// [Required] public string PlanId { get; set; } + + public ChangePlan ToCommand() + { + return SimpleMapper.Map(this, new ChangePlan()); + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Plans/Models/PlanDto.cs b/src/Squidex/Areas/Api/Controllers/Plans/Models/PlanDto.cs index 59adb8595..b0c2f7de9 100644 --- a/src/Squidex/Areas/Api/Controllers/Plans/Models/PlanDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Plans/Models/PlanDto.cs @@ -5,6 +5,10 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Entities.Apps.Services; +using Squidex.Infrastructure.Reflection; + namespace Squidex.Areas.Api.Controllers.Plans.Models { public sealed class PlanDto @@ -12,18 +16,31 @@ namespace Squidex.Areas.Api.Controllers.Plans.Models /// /// The id of the plan. /// + [Required] public string Id { get; set; } /// /// The name of the plan. /// + [Required] public string Name { get; set; } /// /// The monthly costs of the plan. /// + [Required] public string Costs { get; set; } + /// + /// The yearly costs of the plan. + /// + public string YearlyCosts { get; set; } + + /// + /// The yearly id of the plan. + /// + public string YearlyId { get; set; } + /// /// The maximum number of API calls. /// @@ -38,5 +55,10 @@ namespace Squidex.Areas.Api.Controllers.Plans.Models /// The maximum number of contributors. /// public int MaxContributors { get; set; } + + public static PlanDto FromPlan(IAppLimitsPlan plan) + { + return SimpleMapper.Map(plan, new PlanDto()); + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/AlgoliaActionDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/AlgoliaActionDto.cs deleted file mode 100644 index e561386c9..000000000 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/AlgoliaActionDto.cs +++ /dev/null @@ -1,42 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.ComponentModel.DataAnnotations; -using NJsonSchema.Annotations; -using Squidex.Domain.Apps.Core.Rules; -using Squidex.Domain.Apps.Core.Rules.Actions; -using Squidex.Infrastructure.Reflection; - -namespace Squidex.Areas.Api.Controllers.Rules.Models.Actions -{ - [JsonSchema("Algolia")] - public sealed class AlgoliaActionDto : RuleActionDto - { - /// - /// The application ID. - /// - [Required] - public string AppId { get; set; } - - /// - /// The API key to grant access to Squidex. - /// - [Required] - public string ApiKey { get; set; } - - /// - /// The name of the index. - /// - [Required] - public string IndexName { get; set; } - - public override RuleAction ToAction() - { - return SimpleMapper.Map(this, new AlgoliaAction()); - } - } -} diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/AzureQueueActionDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/AzureQueueActionDto.cs deleted file mode 100644 index bfcafb1ac..000000000 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/AzureQueueActionDto.cs +++ /dev/null @@ -1,36 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.ComponentModel.DataAnnotations; -using NJsonSchema.Annotations; -using Squidex.Domain.Apps.Core.Rules; -using Squidex.Domain.Apps.Core.Rules.Actions; -using Squidex.Infrastructure.Reflection; - -namespace Squidex.Areas.Api.Controllers.Rules.Models.Actions -{ - [JsonSchema("AzureQueue")] - public class AzureQueueActionDto : RuleActionDto - { - /// - /// The connection string to the storage account. - /// - [Required] - public string ConnectionString { get; set; } - - /// - /// The queue name. - /// - [Required] - public string Queue { get; set; } - - public override RuleAction ToAction() - { - return SimpleMapper.Map(this, new AzureQueueAction()); - } - } -} diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/ElasticSearchActionDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/ElasticSearchActionDto.cs deleted file mode 100644 index 68ba5758e..000000000 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/ElasticSearchActionDto.cs +++ /dev/null @@ -1,53 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.ComponentModel.DataAnnotations; -using NJsonSchema.Annotations; -using Squidex.Domain.Apps.Core.Rules; -using Squidex.Domain.Apps.Core.Rules.Actions; -using Squidex.Infrastructure.Reflection; - -namespace Squidex.Areas.Api.Controllers.Rules.Models.Actions -{ - [JsonSchema("ElasticSearch")] - public sealed class ElasticSearchActionDto : RuleActionDto - { - /// - /// The host to the elastic search instance. - /// - [Required] - public Uri Host { get; set; } - - /// - /// The name of the index. - /// - [Required] - public string IndexName { get; set; } - - /// - /// The name of the index type. - /// - [Required] - public string IndexType { get; set; } - - /// - /// The optional username for authentication. - /// - public string Username { get; set; } - - /// - /// The optional password for authentication. - /// - public string Password { get; set; } - - public override RuleAction ToAction() - { - return SimpleMapper.Map(this, new ElasticSearchAction()); - } - } -} diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/FastlyActionDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/FastlyActionDto.cs deleted file mode 100644 index c9431f02e..000000000 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/FastlyActionDto.cs +++ /dev/null @@ -1,36 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.ComponentModel.DataAnnotations; -using NJsonSchema.Annotations; -using Squidex.Domain.Apps.Core.Rules; -using Squidex.Domain.Apps.Core.Rules.Actions; -using Squidex.Infrastructure.Reflection; - -namespace Squidex.Areas.Api.Controllers.Rules.Models.Actions -{ - [JsonSchema("Fastly")] - public sealed class FastlyActionDto : RuleActionDto - { - /// - /// The ID of the fastly service. - /// - [Required] - public string ServiceId { get; set; } - - /// - /// The API key to grant access to Squidex. - /// - [Required] - public string ApiKey { get; set; } - - public override RuleAction ToAction() - { - return SimpleMapper.Map(this, new FastlyAction()); - } - } -} diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/SlackActionDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/SlackActionDto.cs deleted file mode 100644 index 553db29d1..000000000 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/SlackActionDto.cs +++ /dev/null @@ -1,36 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.ComponentModel.DataAnnotations; -using NJsonSchema.Annotations; -using Squidex.Domain.Apps.Core.Rules; -using Squidex.Domain.Apps.Core.Rules.Actions; -using Squidex.Infrastructure.Reflection; - -namespace Squidex.Areas.Api.Controllers.Rules.Models.Actions -{ - [JsonSchema("Slack")] - public sealed class SlackActionDto : RuleActionDto - { - /// - /// The slack webhook url. - /// - [Required] - public Uri WebhookUrl { get; set; } - - /// - /// The text that is sent as message to slack. - /// - public string Text { get; set; } - - public override RuleAction ToAction() - { - return SimpleMapper.Map(this, new SlackAction()); - } - } -} diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/WebhookActionDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/WebhookActionDto.cs deleted file mode 100644 index 64feb9189..000000000 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/WebhookActionDto.cs +++ /dev/null @@ -1,36 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.ComponentModel.DataAnnotations; -using NJsonSchema.Annotations; -using Squidex.Domain.Apps.Core.Rules; -using Squidex.Domain.Apps.Core.Rules.Actions; -using Squidex.Infrastructure.Reflection; - -namespace Squidex.Areas.Api.Controllers.Rules.Models.Actions -{ - [JsonSchema("Webhook")] - public sealed class WebhookActionDto : RuleActionDto - { - /// - /// The url of the rule. - /// - [Required] - public Uri Url { get; set; } - - /// - /// The shared secret that is used to calculate the signature. - /// - public string SharedSecret { get; set; } - - public override RuleAction ToAction() - { - return SimpleMapper.Map(this, new WebhookAction()); - } - } -} diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleActionDtoFactory.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleActionDtoFactory.cs deleted file mode 100644 index c2b50b831..000000000 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleActionDtoFactory.cs +++ /dev/null @@ -1,58 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Squidex.Areas.Api.Controllers.Rules.Models.Actions; -using Squidex.Domain.Apps.Core.Rules; -using Squidex.Domain.Apps.Core.Rules.Actions; -using Squidex.Infrastructure.Reflection; - -namespace Squidex.Areas.Api.Controllers.Rules.Models.Converters -{ - public sealed class RuleActionDtoFactory : IRuleActionVisitor - { - private static readonly RuleActionDtoFactory Instance = new RuleActionDtoFactory(); - - private RuleActionDtoFactory() - { - } - - public static RuleActionDto Create(RuleAction properties) - { - return properties.Accept(Instance); - } - - public RuleActionDto Visit(AlgoliaAction action) - { - return SimpleMapper.Map(action, new AlgoliaActionDto()); - } - - public RuleActionDto Visit(AzureQueueAction action) - { - return SimpleMapper.Map(action, new AzureQueueActionDto()); - } - - public RuleActionDto Visit(ElasticSearchAction action) - { - return SimpleMapper.Map(action, new ElasticSearchActionDto()); - } - - public RuleActionDto Visit(FastlyAction action) - { - return SimpleMapper.Map(action, new FastlyActionDto()); - } - - public RuleActionDto Visit(SlackAction action) - { - return SimpleMapper.Map(action, new SlackActionDto()); - } - - public RuleActionDto Visit(WebhookAction action) - { - return SimpleMapper.Map(action, new WebhookActionDto()); - } - } -} diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleConverter.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleConverter.cs deleted file mode 100644 index a5c6fa003..000000000 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleConverter.cs +++ /dev/null @@ -1,71 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using Squidex.Domain.Apps.Entities.Rules; -using Squidex.Domain.Apps.Entities.Rules.Commands; -using Squidex.Infrastructure.Reflection; - -namespace Squidex.Areas.Api.Controllers.Rules.Models.Converters -{ - public static class RuleConverter - { - public static RuleDto ToModel(this IRuleEntity entity) - { - var dto = new RuleDto(); - - SimpleMapper.Map(entity, dto); - SimpleMapper.Map(entity.RuleDef, dto); - - if (entity.RuleDef.Trigger != null) - { - dto.Trigger = RuleTriggerDtoFactory.Create(entity.RuleDef.Trigger); - } - - if (entity.RuleDef.Action != null) - { - dto.Action = RuleActionDtoFactory.Create(entity.RuleDef.Action); - } - - return dto; - } - - public static UpdateRule ToCommand(this UpdateRuleDto dto, Guid id) - { - var command = new UpdateRule { RuleId = id }; - - if (dto.Action != null) - { - command.Action = dto.Action.ToAction(); - } - - if (dto.Trigger != null) - { - command.Trigger = dto.Trigger.ToTrigger(); - } - - return command; - } - - public static CreateRule ToCommand(this CreateRuleDto dto) - { - var command = new CreateRule(); - - if (dto.Action != null) - { - command.Action = dto.Action.ToAction(); - } - - if (dto.Trigger != null) - { - command.Trigger = dto.Trigger.ToTrigger(); - } - - return command; - } - } -} diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/CreateRuleDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/CreateRuleDto.cs index 1256163e3..e199ed4e2 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/CreateRuleDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/CreateRuleDto.cs @@ -6,6 +6,9 @@ // ========================================================================== using System.ComponentModel.DataAnnotations; +using Newtonsoft.Json; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Domain.Apps.Entities.Rules.Commands; namespace Squidex.Areas.Api.Controllers.Rules.Models { @@ -21,6 +24,19 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models /// The action properties. /// [Required] - public RuleActionDto Action { get; set; } + [JsonConverter(typeof(RuleActionSerializer))] + public RuleAction Action { get; set; } + + public CreateRule ToCommand() + { + var command = new CreateRule { Action = Action }; + + if (Trigger != null) + { + command.Trigger = Trigger.ToTrigger(); + } + + return command; + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionDto.cs deleted file mode 100644 index 5cf41aa29..000000000 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionDto.cs +++ /dev/null @@ -1,29 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Linq; -using System.Runtime.Serialization; -using Newtonsoft.Json; -using Squidex.Domain.Apps.Core.Rules; - -namespace Squidex.Areas.Api.Controllers.Rules.Models -{ - [JsonConverter(typeof(JsonInheritanceConverter), "actionType", typeof(RuleActionDto))] - [KnownType(nameof(Subtypes))] - public abstract class RuleActionDto - { - public abstract RuleAction ToAction(); - - public static Type[] Subtypes() - { - var type = typeof(RuleActionDto); - - return type.Assembly.GetTypes().Where(type.IsAssignableFrom).ToArray(); - } - } -} diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionProcessor.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionProcessor.cs new file mode 100644 index 000000000..c2a93b912 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionProcessor.cs @@ -0,0 +1,63 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Linq; +using System.Threading.Tasks; +using NJsonSchema; +using NSwag.SwaggerGeneration.Processors; +using NSwag.SwaggerGeneration.Processors.Contexts; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Extensions.Actions; + +namespace Squidex.Areas.Api.Controllers.Rules.Models +{ + public sealed class RuleActionProcessor : IDocumentProcessor + { + public async Task ProcessAsync(DocumentProcessorContext context) + { + var schema = context.SchemaResolver.GetSchema(typeof(RuleAction), false); + + if (schema != null) + { + var discriminator = new OpenApiDiscriminator + { + JsonInheritanceConverter = new JsonInheritanceConverter("actionType", typeof(RuleAction)), + PropertyName = "actionType" + }; + + schema.DiscriminatorObject = discriminator; + schema.Properties["actionType"] = new JsonProperty + { + Type = JsonObjectType.String, + IsRequired = true + }; + + foreach (var derived in RuleElementRegistry.Actions) + { + var derivedSchema = await context.SchemaGenerator.GenerateAsync(derived.Value.Type, context.SchemaResolver); + + var oldName = context.Document.Definitions.FirstOrDefault(x => x.Value == derivedSchema).Key; + + if (oldName != null) + { + context.Document.Definitions.Remove(oldName); + context.Document.Definitions.Add(derived.Key, derivedSchema); + } + } + + RemoveFreezable(context, schema); + } + } + + private static void RemoveFreezable(DocumentProcessorContext context, JsonSchema4 schema) + { + context.Document.Definitions.Remove("Freezable"); + + schema.AllOf.Clear(); + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionSerializer.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionSerializer.cs new file mode 100644 index 000000000..9de13838a --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionSerializer.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Linq; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Extensions.Actions; + +namespace Squidex.Areas.Api.Controllers.Rules.Models +{ + public sealed class RuleActionSerializer : JsonInheritanceConverter + { + public RuleActionSerializer() + : base("actionType", typeof(RuleAction), RuleElementRegistry.Actions.ToDictionary(x => x.Key, x => x.Value.Type)) + { + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs index 487dbb243..c94056f37 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs @@ -7,8 +7,13 @@ using System; using System.ComponentModel.DataAnnotations; +using Newtonsoft.Json; using NodaTime; +using Squidex.Areas.Api.Controllers.Rules.Models.Converters; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Domain.Apps.Entities.Rules; using Squidex.Infrastructure; +using Squidex.Infrastructure.Reflection; namespace Squidex.Areas.Api.Controllers.Rules.Models { @@ -46,6 +51,11 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models /// public int Version { get; set; } + /// + /// Determines if the rule is enabled. + /// + public bool IsEnabled { get; set; } + /// /// The trigger properties. /// @@ -56,11 +66,22 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models /// The action properties. /// [Required] - public RuleActionDto Action { get; set; } + [JsonConverter(typeof(RuleActionSerializer))] + public RuleAction Action { get; set; } - /// - /// Determines if the rule is enabled. - /// - public bool IsEnabled { get; set; } + public static RuleDto FromRule(IRuleEntity rule) + { + var response = new RuleDto(); + + SimpleMapper.Map(rule, response); + SimpleMapper.Map(rule.RuleDef, response); + + if (rule.RuleDef.Trigger != null) + { + response.Trigger = RuleTriggerDtoFactory.Create(rule.RuleDef.Trigger); + } + + return response; + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleElementDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleElementDto.cs new file mode 100644 index 000000000..e419be526 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleElementDto.cs @@ -0,0 +1,41 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; + +namespace Squidex.Areas.Api.Controllers.Rules.Models +{ + public sealed class RuleElementDto + { + /// + /// Describes the action or trigger type. + /// + [Required] + public string Description { get; set; } + + /// + /// The label for the action or trigger type. + /// + [Required] + public string Display { get; set; } + + /// + /// The color for the icon. + /// + public string IconColor { get; set; } + + /// + /// The image for the icon. + /// + public string IconImage { get; set; } + + /// + /// The optional link to the product that is integrated. + /// + public string ReadMore { get; set; } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleEventDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleEventDto.cs index 87fa08276..68f2bc56a 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleEventDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleEventDto.cs @@ -10,6 +10,7 @@ using System.ComponentModel.DataAnnotations; using NodaTime; using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Entities.Rules; +using Squidex.Infrastructure.Reflection; namespace Squidex.Areas.Api.Controllers.Rules.Models { @@ -61,5 +62,15 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models /// The result of the job. /// public RuleJobResult JobResult { get; set; } + + public static RuleEventDto FromRuleEvent(IRuleEventEntity ruleEvent) + { + var response = new RuleEventDto(); + + SimpleMapper.Map(ruleEvent, response); + SimpleMapper.Map(ruleEvent.Job, response); + + return response; + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleEventsDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleEventsDto.cs index 6121e6edf..ef320b78d 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleEventsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleEventsDto.cs @@ -5,18 +5,29 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Squidex.Domain.Apps.Entities.Rules; + namespace Squidex.Areas.Api.Controllers.Rules.Models { public sealed class RuleEventsDto { /// - /// The total number of rule events. + /// The rule events. /// - public long Total { get; set; } + [Required] + public RuleEventDto[] Items { get; set; } /// - /// The rule events. + /// The total number of rule events. /// - public RuleEventDto[] Items { get; set; } + public long Total { get; set; } + + public static RuleEventsDto FromRuleEvents(IReadOnlyList items, long total) + { + return new RuleEventsDto { Total = total, Items = items.Select(RuleEventDto.FromRuleEvent).ToArray() }; + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedTriggerSchemaDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedTriggerSchemaDto.cs index dacd359b3..2a16e9639 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedTriggerSchemaDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedTriggerSchemaDto.cs @@ -35,5 +35,20 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models.Triggers /// Determines whether to handle the event when a content is published. /// public bool SendPublish { get; set; } + + /// + /// Determines whether to handle the event when a content is unpublished. + /// + public bool SendUnpublish { get; set; } + + /// + /// Determines whether to handle the event when a content is archived. + /// + public bool SendArchived { get; set; } + + /// + /// Determines whether to handle the event when a content is restored. + /// + public bool SendRestore { get; set; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/UpdateRuleDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/UpdateRuleDto.cs index b0fa8bc5b..831a0c5f6 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/UpdateRuleDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/UpdateRuleDto.cs @@ -5,6 +5,11 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; +using Newtonsoft.Json; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Domain.Apps.Entities.Rules.Commands; + namespace Squidex.Areas.Api.Controllers.Rules.Models { public sealed class UpdateRuleDto @@ -17,6 +22,19 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models /// /// The action properties. /// - public RuleActionDto Action { get; set; } + [JsonConverter(typeof(RuleActionSerializer))] + public RuleAction Action { get; set; } + + public UpdateRule ToCommand(Guid id) + { + var command = new UpdateRule { RuleId = id, Action = Action }; + + if (Trigger != null) + { + command.Trigger = Trigger.ToTrigger(); + } + + return command; + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs b/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs index 12e30ea00..1170416ce 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs @@ -6,16 +6,17 @@ // ========================================================================== using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using NodaTime; using NSwag.Annotations; using Squidex.Areas.Api.Controllers.Rules.Models; -using Squidex.Areas.Api.Controllers.Rules.Models.Converters; using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Rules.Commands; using Squidex.Domain.Apps.Entities.Rules.Repositories; +using Squidex.Extensions.Actions; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Reflection; using Squidex.Pipeline; @@ -44,6 +45,40 @@ namespace Squidex.Areas.Api.Controllers.Rules this.ruleEventsRepository = ruleEventsRepository; } + /// + /// Get the supported rule actions. + /// + /// + /// 200 => Rule actions returned. + /// + [HttpGet] + [Route("rules/actions/")] + [ProducesResponseType(typeof(Dictionary), 200)] + [ApiCosts(0)] + public IActionResult GetActions() + { + var response = RuleElementRegistry.Actions.ToDictionary(x => x.Key, x => SimpleMapper.Map(x.Value, new RuleElementDto())); + + return Ok(response); + } + + /// + /// Get the supported rule triggers. + /// + /// + /// 200 => Rule triggers returned. + /// + [HttpGet] + [Route("rules/triggers/")] + [ProducesResponseType(typeof(Dictionary), 200)] + [ApiCosts(0)] + public IActionResult GetTriggers() + { + var response = RuleElementRegistry.Triggers.ToDictionary(x => x.Key, x => SimpleMapper.Map(x.Value, new RuleElementDto())); + + return Ok(response); + } + /// /// Get rules. /// @@ -58,9 +93,9 @@ namespace Squidex.Areas.Api.Controllers.Rules [ApiCosts(1)] public async Task GetRules(string app) { - var rules = await appProvider.GetRulesAsync(AppId); + var entities = await appProvider.GetRulesAsync(AppId); - var response = rules.Select(r => r.ToModel()); + var response = entities.Select(RuleDto.FromRule); return Ok(response); } @@ -82,12 +117,10 @@ namespace Squidex.Areas.Api.Controllers.Rules [ApiCosts(1)] public async Task PostRule(string app, [FromBody] CreateRuleDto request) { - var command = request.ToCommand(); - - var context = await CommandBus.PublishAsync(command); + var context = await CommandBus.PublishAsync(request.ToCommand()); var result = context.Result>(); - var response = new EntityCreatedDto { Id = result.IdOrValue.ToString(), Version = result.Version }; + var response = EntityCreatedDto.FromResult(result); return CreatedAtAction(nameof(GetRules), new { app }, response); } @@ -112,9 +145,7 @@ namespace Squidex.Areas.Api.Controllers.Rules [ApiCosts(1)] public async Task PutRule(string app, Guid id, [FromBody] UpdateRuleDto request) { - var command = request.ToCommand(id); - - await CommandBus.PublishAsync(command); + await CommandBus.PublishAsync(request.ToCommand(id)); return NoContent(); } @@ -199,19 +230,7 @@ namespace Squidex.Areas.Api.Controllers.Rules await Task.WhenAll(taskForItems, taskForCount); - var response = new RuleEventsDto - { - Total = taskForCount.Result, - Items = taskForItems.Result.Select(x => - { - var itemModel = new RuleEventDto(); - - SimpleMapper.Map(x, itemModel); - SimpleMapper.Map(x.Job, itemModel); - - return itemModel; - }).ToArray() - }; + var response = RuleEventsDto.FromRuleEvents(taskForItems.Result, taskForCount.Result); return Ok(response); } diff --git a/src/Squidex/Areas/Api/Controllers/Rules/TwitterController.cs b/src/Squidex/Areas/Api/Controllers/Rules/TwitterController.cs new file mode 100644 index 000000000..aa9980421 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Rules/TwitterController.cs @@ -0,0 +1,69 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Squidex.Extensions.Actions.Twitter; +using static CoreTweet.OAuth; + +namespace Squidex.Areas.Api.Controllers.Rules +{ + public sealed class TwitterController : Controller + { + private readonly TwitterOptions twitterOptions; + + public TwitterController(IOptions twitterOptions) + { + this.twitterOptions = twitterOptions.Value; + } + + public sealed class TokenRequest + { + public string PinCode { get; set; } + + public string RequestToken { get; set; } + + public string RequestTokenSecret { get; set; } + } + + [HttpGet] + [Route("rules/twitter/auth")] + public async Task Auth() + { + var session = await AuthorizeAsync(twitterOptions.ClientId, twitterOptions.ClientSecret); + + return Ok(new + { + session.AuthorizeUri, + session.RequestToken, + session.RequestTokenSecret + }); + } + + [HttpPost] + [Route("rules/twitter/token")] + public async Task AuthComplete([FromBody] TokenRequest request) + { + var session = new OAuthSession + { + ConsumerKey = twitterOptions.ClientId, + ConsumerSecret = twitterOptions.ClientSecret, + RequestToken = request.RequestToken, + RequestTokenSecret = request.RequestTokenSecret + }; + + var tokens = await session.GetTokensAsync(request.PinCode); + + return Ok(new + { + tokens.AccessToken, + tokens.AccessTokenSecret + }); + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/AddFieldDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/AddFieldDto.cs index 47de95905..b2330cfd8 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/AddFieldDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/AddFieldDto.cs @@ -6,6 +6,8 @@ // ========================================================================== using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Entities.Schemas.Commands; +using Squidex.Infrastructure.Reflection; namespace Squidex.Areas.Api.Controllers.Schemas.Models { @@ -28,5 +30,10 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models /// [Required] public FieldPropertiesDto Properties { get; set; } + + public AddField ToCommand(long? parentId = null) + { + return SimpleMapper.Map(this, new AddField { ParentFieldId = parentId, Properties = Properties.ToProperties() }); + } } } \ No newline at end of file diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/ChangeCategoryDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/ChangeCategoryDto.cs new file mode 100644 index 000000000..29e497ec6 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/ChangeCategoryDto.cs @@ -0,0 +1,25 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Entities.Schemas.Commands; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Areas.Api.Controllers.Schemas.Models +{ + public sealed class ChangeCategoryDto + { + /// + /// The name of the category. + /// + public string Name { get; set; } + + public ChangeCategory ToCommand() + { + return SimpleMapper.Map(this, new ChangeCategory()); + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/ConfigureScriptsDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/ConfigureScriptsDto.cs index 2f4df4a37..51c8c3de1 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/ConfigureScriptsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/ConfigureScriptsDto.cs @@ -5,6 +5,9 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Squidex.Domain.Apps.Entities.Schemas.Commands; +using Squidex.Infrastructure.Reflection; + namespace Squidex.Areas.Api.Controllers.Schemas.Models { public sealed class ConfigureScriptsDto @@ -33,5 +36,10 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models /// The script that is executed when change a content status. /// public string ScriptChange { get; set; } + + public ConfigureScripts ToCommand() + { + return SimpleMapper.Map(this, new ConfigureScripts()); + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/Converters/FieldPropertiesDtoFactory.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/Converters/FieldPropertiesDtoFactory.cs index 5dd3b2129..9750fc7c4 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/Converters/FieldPropertiesDtoFactory.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/Converters/FieldPropertiesDtoFactory.cs @@ -25,6 +25,11 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models.Converters return properties.Accept(Instance); } + public FieldPropertiesDto Visit(ArrayFieldProperties properties) + { + return SimpleMapper.Map(properties, new ArrayFieldPropertiesDto()); + } + public FieldPropertiesDto Visit(BooleanFieldProperties properties) { return SimpleMapper.Map(properties, new BooleanFieldPropertiesDto()); diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/Converters/SchemaConverter.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/Converters/SchemaConverter.cs deleted file mode 100644 index 4d70c1f8f..000000000 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/Converters/SchemaConverter.cs +++ /dev/null @@ -1,85 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Collections.Generic; -using Squidex.Domain.Apps.Core.Schemas; -using Squidex.Domain.Apps.Entities.Schemas; -using Squidex.Domain.Apps.Entities.Schemas.Commands; -using Squidex.Infrastructure.Reflection; - -namespace Squidex.Areas.Api.Controllers.Schemas.Models.Converters -{ - public static class SchemaConverter - { - public static SchemaDto ToModel(this ISchemaEntity entity) - { - var dto = new SchemaDto { Properties = new SchemaPropertiesDto() }; - - SimpleMapper.Map(entity, dto); - SimpleMapper.Map(entity.SchemaDef, dto); - SimpleMapper.Map(entity.SchemaDef.Properties, dto.Properties); - - return dto; - } - - public static SchemaDetailsDto ToDetailsModel(this ISchemaEntity entity) - { - var dto = new SchemaDetailsDto { Properties = new SchemaPropertiesDto() }; - - SimpleMapper.Map(entity, dto); - SimpleMapper.Map(entity.SchemaDef, dto); - SimpleMapper.Map(entity.SchemaDef.Properties, dto.Properties); - - dto.Fields = new List(); - - foreach (var field in entity.SchemaDef.Fields) - { - var fieldPropertiesDto = FieldPropertiesDtoFactory.Create(field.RawProperties); - var fieldInstanceDto = SimpleMapper.Map(field, - new FieldDto - { - FieldId = field.Id, - Properties = fieldPropertiesDto, - Partitioning = field.Partitioning.Key - }); - - dto.Fields.Add(fieldInstanceDto); - } - - return dto; - } - - public static CreateSchema ToCommand(this CreateSchemaDto dto) - { - var command = new CreateSchema(); - - SimpleMapper.Map(dto, command); - - if (dto.Properties != null) - { - command.Properties = new SchemaProperties(); - - SimpleMapper.Map(dto.Properties, command.Properties); - } - - if (dto.Fields != null) - { - command.Fields = new List(); - - foreach (var fieldDto in dto.Fields) - { - var fieldProperties = fieldDto?.Properties.ToProperties(); - var fieldInstance = SimpleMapper.Map(fieldDto, new CreateSchemaField { Properties = fieldProperties }); - - command.Fields.Add(fieldInstance); - } - } - - return command; - } - } -} diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateSchemaDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateSchemaDto.cs index cb6a04ae7..922647abc 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateSchemaDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateSchemaDto.cs @@ -7,6 +7,9 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Schemas.Commands; +using Squidex.Infrastructure.Reflection; namespace Squidex.Areas.Api.Controllers.Schemas.Models { @@ -29,9 +32,56 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models /// public List Fields { get; set; } + /// + /// Set to true to allow a single content item only. + /// + public bool Singleton { get; set; } + /// /// Set it to true to autopublish the schema. /// public bool Publish { get; set; } + + public CreateSchema ToCommand() + { + var command = new CreateSchema(); + + SimpleMapper.Map(this, command); + + if (Properties != null) + { + command.Properties = new SchemaProperties(); + + SimpleMapper.Map(Properties, command.Properties); + } + + if (Fields != null) + { + command.Fields = new List(); + + foreach (var fieldDto in Fields) + { + var rootProperties = fieldDto?.Properties.ToProperties(); + var rootField = SimpleMapper.Map(fieldDto, new CreateSchemaField { Properties = rootProperties }); + + if (fieldDto.Nested != null) + { + rootField.Nested = new List(); + + foreach (var nestedFieldDto in fieldDto.Nested) + { + var nestedProperties = nestedFieldDto?.Properties.ToProperties(); + var nestedField = SimpleMapper.Map(nestedFieldDto, new CreateSchemaNestedField { Properties = nestedProperties }); + + rootField.Nested.Add(nestedField); + } + } + + command.Fields.Add(rootField); + } + } + + return command; + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateSchemaFieldDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateSchemaFieldDto.cs index a96975669..75a09a657 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateSchemaFieldDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateSchemaFieldDto.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; namespace Squidex.Areas.Api.Controllers.Schemas.Models @@ -43,5 +44,10 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models /// [Required] public FieldPropertiesDto Properties { get; set; } + + /// + /// The nested fields. + /// + public List Nested { get; set; } } } \ No newline at end of file diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateSchemaNestedFieldDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateSchemaNestedFieldDto.cs new file mode 100644 index 000000000..f25e2cadb --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateSchemaNestedFieldDto.cs @@ -0,0 +1,37 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; + +namespace Squidex.Areas.Api.Controllers.Schemas.Models +{ + public sealed class CreateSchemaNestedFieldDto + { + /// + /// The name of the field. Must be unique within the schema. + /// + [Required] + [RegularExpression("^[a-zA-Z0-9]+(\\-[a-zA-Z0-9]+)*$")] + public string Name { get; set; } + + /// + /// Defines if the field is hidden. + /// + public bool IsHidden { get; set; } + + /// + /// Defines if the field is disabled. + /// + public bool IsDisabled { get; set; } + + /// + /// The field properties. + /// + [Required] + public FieldPropertiesDto Properties { get; set; } + } +} \ No newline at end of file diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldDto.cs index 5296b1ece..d9eab980f 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldDto.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; namespace Squidex.Areas.Api.Controllers.Schemas.Models @@ -49,5 +50,10 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models /// [Required] public FieldPropertiesDto Properties { get; set; } + + /// + /// The nested fields. + /// + public List Nested { get; set; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldPropertiesDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldPropertiesDto.cs index afd3e8094..ed0e6accc 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldPropertiesDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldPropertiesDto.cs @@ -46,6 +46,11 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models /// public bool IsListField { get; set; } + /// + /// Optional url to the editor. + /// + public string EditorUrl { get; set; } + /// /// Gets the partitioning of the language, e.g. invariant or language. /// @@ -55,7 +60,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models public static Type[] Subtypes() { - var type = typeof(SchemaPropertiesDto); + var type = typeof(FieldPropertiesDto); return type.Assembly.GetTypes().Where(type.IsAssignableFrom).ToArray(); } diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/ArrayFieldPropertiesDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/ArrayFieldPropertiesDto.cs new file mode 100644 index 000000000..b818b6815 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/Fields/ArrayFieldPropertiesDto.cs @@ -0,0 +1,34 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using NJsonSchema.Annotations; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Areas.Api.Controllers.Schemas.Models.Fields +{ + [JsonSchema("Array")] + public sealed class ArrayFieldPropertiesDto : FieldPropertiesDto + { + /// + /// The minimum allowed items for the field value. + /// + public int? MinItems { get; set; } + + /// + /// The maximum allowed items for the field value. + /// + public int? MaxItems { get; set; } + + public override FieldProperties ToProperties() + { + var result = SimpleMapper.Map(this, new ArrayFieldProperties()); + + return result; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/NestedFieldDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/NestedFieldDto.cs new file mode 100644 index 000000000..334c50376 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/NestedFieldDto.cs @@ -0,0 +1,47 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; + +namespace Squidex.Areas.Api.Controllers.Schemas.Models +{ + public sealed class NestedFieldDto + { + /// + /// The id of the field. + /// + public long FieldId { get; set; } + + /// + /// The name of the field. Must be unique within the schema. + /// + [Required] + [RegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")] + public string Name { get; set; } + + /// + /// Defines if the field is hidden. + /// + public bool IsHidden { get; set; } + + /// + /// Defines if the field is locked. + /// + public bool IsLocked { get; set; } + + /// + /// Defines if the field is disabled. + /// + public bool IsDisabled { get; set; } + + /// + /// The field properties. + /// + [Required] + public FieldPropertiesDto Properties { get; set; } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/ReorderFieldsDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/ReorderFieldsDto.cs index 6e5e78090..ab28c7f99 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/ReorderFieldsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/ReorderFieldsDto.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Entities.Schemas.Commands; namespace Squidex.Areas.Api.Controllers.Schemas.Models { @@ -17,5 +18,10 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models /// [Required] public List FieldIds { get; set; } + + public ReorderFields ToCommand(long? parentId = null) + { + return new ReorderFields { ParentFieldId = parentId, FieldIds = FieldIds }; + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs index 1dcc57863..0dc33bc92 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs @@ -9,7 +9,11 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using NodaTime; +using Squidex.Areas.Api.Controllers.Schemas.Models.Converters; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; +using Squidex.Infrastructure.Reflection; namespace Squidex.Areas.Api.Controllers.Schemas.Models { @@ -27,6 +31,16 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models [RegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")] public string Name { get; set; } + /// + /// The name of the category. + /// + public string Category { get; set; } + + /// + /// Indicates if the schema is a singleton. + /// + public bool IsSingleton { get; set; } + /// /// Indicates if the schema is published. /// @@ -95,5 +109,50 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models /// The version of the schema. /// public int Version { get; set; } + + public static SchemaDetailsDto FromSchema(ISchemaEntity schema) + { + var response = new SchemaDetailsDto { Properties = new SchemaPropertiesDto() }; + + SimpleMapper.Map(schema, response); + SimpleMapper.Map(schema.SchemaDef, response); + SimpleMapper.Map(schema.SchemaDef.Properties, response.Properties); + + response.Fields = new List(); + + foreach (var field in schema.SchemaDef.Fields) + { + var fieldPropertiesDto = FieldPropertiesDtoFactory.Create(field.RawProperties); + var fieldDto = SimpleMapper.Map(field, + new FieldDto + { + FieldId = field.Id, + Properties = fieldPropertiesDto, + Partitioning = field.Partitioning.Key + }); + + if (field is IArrayField arrayField) + { + fieldDto.Nested = new List(); + + foreach (var nestedField in arrayField.Fields) + { + var nestedFieldPropertiesDto = FieldPropertiesDtoFactory.Create(nestedField.RawProperties); + var nestedFieldDto = SimpleMapper.Map(nestedField, + new NestedFieldDto + { + FieldId = nestedField.Id, + Properties = nestedFieldPropertiesDto + }); + + fieldDto.Nested.Add(nestedFieldDto); + } + } + + response.Fields.Add(fieldDto); + } + + return response; + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs index 08e8c49a8..a8e3e367e 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs @@ -8,11 +8,14 @@ using System; using System.ComponentModel.DataAnnotations; using NodaTime; +using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; +using Squidex.Infrastructure.Reflection; +using Squidex.Pipeline; namespace Squidex.Areas.Api.Controllers.Schemas.Models { - public sealed class SchemaDto + public sealed class SchemaDto : IGenerateEtag { /// /// The id of the schema. @@ -26,12 +29,22 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models [RegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")] public string Name { get; set; } + /// + /// The name of the category. + /// + public string Category { get; set; } + /// /// The schema properties. /// [Required] public SchemaPropertiesDto Properties { get; set; } + /// + /// Indicates if the schema is a singleton. + /// + public bool IsSingleton { get; set; } + /// /// Indicates if the schema is published. /// @@ -62,6 +75,17 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models /// /// The version of the schema. /// - public int Version { get; set; } + public long Version { get; set; } + + public static SchemaDto FromSchema(ISchemaEntity schema) + { + var response = new SchemaDto { Properties = new SchemaPropertiesDto() }; + + SimpleMapper.Map(schema, response); + SimpleMapper.Map(schema.SchemaDef, response); + SimpleMapper.Map(schema.SchemaDef.Properties, response.Properties); + + return response; + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/UpdateFieldDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/UpdateFieldDto.cs index c202f9a5b..0a55fe8e9 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/UpdateFieldDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/UpdateFieldDto.cs @@ -6,6 +6,7 @@ // ========================================================================== using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Entities.Schemas.Commands; namespace Squidex.Areas.Api.Controllers.Schemas.Models { @@ -16,5 +17,10 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models /// [Required] public FieldPropertiesDto Properties { get; set; } + + public UpdateField ToCommand(long id, long? parentId = null) + { + return new UpdateField { ParentFieldId = parentId, FieldId = id, Properties = Properties?.ToProperties() }; + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/UpdateSchemaDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/UpdateSchemaDto.cs index 755c42729..0de26e8df 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/UpdateSchemaDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/UpdateSchemaDto.cs @@ -6,6 +6,9 @@ // ========================================================================== using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Schemas.Commands; +using Squidex.Infrastructure.Reflection; namespace Squidex.Areas.Api.Controllers.Schemas.Models { @@ -22,5 +25,12 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models /// [StringLength(1000)] public string Hints { get; set; } + + public UpdateSchema ToCommand() + { + var properties = SimpleMapper.Map(this, new SchemaProperties()); + + return new UpdateSchema { Properties = properties }; + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs b/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs index 3a12a70d5..0299cee87 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs @@ -50,17 +50,39 @@ namespace Squidex.Areas.Api.Controllers.Schemas [ApiCosts(1)] public async Task PostField(string app, string name, [FromBody] AddFieldDto request) { - var command = new AddField - { - Name = request.Name, - Partitioning = request.Partitioning, - Properties = request.Properties.ToProperties() - }; + var context = await CommandBus.PublishAsync(request.ToCommand()); - var context = await CommandBus.PublishAsync(command); + var result = context.Result>(); + var response = EntityCreatedDto.FromResult(result); + + return StatusCode(201, response); + } + + /// + /// Add a nested schema field. + /// + /// The name of the app. + /// The name of the schema. + /// The parent field id. + /// The field object that needs to be added to the schema. + /// + /// 201 => Schema field created. + /// 400 => Schema field properties not valid. + /// 409 => Schema field name already in use. + /// 404 => Schema, field or app not found. + /// + [HttpPost] + [Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/")] + [ProducesResponseType(typeof(EntityCreatedDto), 201)] + [ProducesResponseType(typeof(ErrorDto), 409)] + [ProducesResponseType(typeof(ErrorDto), 400)] + [ApiCosts(1)] + public async Task PostNestedField(string app, string name, long parentId, [FromBody] AddFieldDto request) + { + var context = await CommandBus.PublishAsync(request.ToCommand(parentId)); var result = context.Result>(); - var response = new EntityCreatedDto { Id = result.IdOrValue.ToString(), Version = result.Version }; + var response = EntityCreatedDto.FromResult(result); return StatusCode(201, response); } @@ -80,11 +102,32 @@ namespace Squidex.Areas.Api.Controllers.Schemas [Route("apps/{app}/schemas/{name}/fields/ordering/")] [ProducesResponseType(typeof(ErrorDto), 400)] [ApiCosts(1)] - public async Task PutFieldOrdering(string app, string name, [FromBody] ReorderFields request) + public async Task PutSchemaFieldOrdering(string app, string name, [FromBody] ReorderFieldsDto request) { - var command = new ReorderFields { FieldIds = request.FieldIds }; + await CommandBus.PublishAsync(request.ToCommand()); - await CommandBus.PublishAsync(command); + return NoContent(); + } + + /// + /// Reorders the nested fields. + /// + /// The name of the app. + /// The name of the schema. + /// The parent field id. + /// The request that contains the field ids. + /// + /// 204 => Schema fields reorderd. + /// 400 => Schema field ids do not cover the fields of the schema. + /// 404 => Schema, field or app not found. + /// + [HttpPut] + [Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/ordering/")] + [ProducesResponseType(typeof(ErrorDto), 400)] + [ApiCosts(1)] + public async Task PutNestedFieldOrdering(string app, string name, long parentId, [FromBody] ReorderFieldsDto request) + { + await CommandBus.PublishAsync(request.ToCommand(parentId)); return NoContent(); } @@ -108,9 +151,32 @@ namespace Squidex.Areas.Api.Controllers.Schemas [ApiCosts(1)] public async Task PutField(string app, string name, long id, [FromBody] UpdateFieldDto request) { - var command = new UpdateField { FieldId = id, Properties = request.Properties.ToProperties() }; + await CommandBus.PublishAsync(request.ToCommand(id)); - await CommandBus.PublishAsync(command); + return NoContent(); + } + + /// + /// Update a nested schema field. + /// + /// The name of the app. + /// The name of the schema. + /// The parent field id. + /// The id of the field to update. + /// The field object that needs to be added to the schema. + /// + /// 204 => Schema field updated. + /// 400 => Schema field properties not valid or field is locked. + /// 404 => Schema, field or app not found. + /// + [HttpPut] + [Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/{id:long}/")] + [ProducesResponseType(typeof(ErrorDto), 409)] + [ProducesResponseType(typeof(ErrorDto), 400)] + [ApiCosts(1)] + public async Task PutNestedField(string app, string name, long parentId, long id, [FromBody] UpdateFieldDto request) + { + await CommandBus.PublishAsync(request.ToCommand(id, parentId)); return NoContent(); } @@ -127,7 +193,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// 404 => Schema, field or app not found. /// /// - /// A hidden field is not part of the API response, but can still be edited in the portal. + /// A locked field cannot be updated or deleted. /// [HttpPut] [Route("apps/{app}/schemas/{name}/fields/{id:long}/lock/")] @@ -135,9 +201,33 @@ namespace Squidex.Areas.Api.Controllers.Schemas [ApiCosts(1)] public async Task LockField(string app, string name, long id) { - var command = new LockField { FieldId = id }; + await CommandBus.PublishAsync(new LockField { FieldId = id }); - await CommandBus.PublishAsync(command); + return NoContent(); + } + + /// + /// Lock a nested schema field. + /// + /// The name of the app. + /// The name of the schema. + /// The parent field id. + /// The id of the field to lock. + /// + /// 204 => Schema field hidden. + /// 400 => Schema field already hidden. + /// 404 => Field, schema, or app not found. + /// + /// + /// A locked field cannot be edited or deleted. + /// + [HttpPut] + [Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/{id:long}/lock/")] + [ProducesResponseType(typeof(ErrorDto), 400)] + [ApiCosts(1)] + public async Task LockNestedField(string app, string name, long parentId, long id) + { + await CommandBus.PublishAsync(new LockField { ParentFieldId = parentId, FieldId = id }); return NoContent(); } @@ -154,7 +244,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// 404 => Schema, field or app not found. /// /// - /// A locked field cannot be edited or deleted. + /// A hidden field is not part of the API response, but can still be edited in the portal. /// [HttpPut] [Route("apps/{app}/schemas/{name}/fields/{id:long}/hide/")] @@ -162,9 +252,33 @@ namespace Squidex.Areas.Api.Controllers.Schemas [ApiCosts(1)] public async Task HideField(string app, string name, long id) { - var command = new HideField { FieldId = id }; + await CommandBus.PublishAsync(new HideField { FieldId = id }); - await CommandBus.PublishAsync(command); + return NoContent(); + } + + /// + /// Hide a nested schema field. + /// + /// The name of the app. + /// The name of the schema. + /// The parent field id. + /// The id of the field to hide. + /// + /// 204 => Schema field hidden. + /// 400 => Schema field already hidden. + /// 404 => Field, schema, or app not found. + /// + /// + /// A hidden field is not part of the API response, but can still be edited in the portal. + /// + [HttpPut] + [Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/{id:long}/hide/")] + [ProducesResponseType(typeof(ErrorDto), 400)] + [ApiCosts(1)] + public async Task HideNestedField(string app, string name, long parentId, long id) + { + await CommandBus.PublishAsync(new HideField { ParentFieldId = parentId, FieldId = id }); return NoContent(); } @@ -189,9 +303,33 @@ namespace Squidex.Areas.Api.Controllers.Schemas [ApiCosts(1)] public async Task ShowField(string app, string name, long id) { - var command = new ShowField { FieldId = id }; + await CommandBus.PublishAsync(new ShowField { FieldId = id }); - await CommandBus.PublishAsync(command); + return NoContent(); + } + + /// + /// Show a nested schema field. + /// + /// The name of the app. + /// The name of the schema. + /// The parent field id. + /// The id of the field to show. + /// + /// 204 => Schema field shown. + /// 400 => Schema field already visible. + /// 404 => Schema, field or app not found. + /// + /// + /// A hidden field is not part of the API response, but can still be edited in the portal. + /// + [HttpPut] + [Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/{id:long}/show/")] + [ProducesResponseType(typeof(ErrorDto), 400)] + [ApiCosts(1)] + public async Task ShowNestedField(string app, string name, long parentId, long id) + { + await CommandBus.PublishAsync(new ShowField { ParentFieldId = parentId, FieldId = id }); return NoContent(); } @@ -208,8 +346,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// 404 => Schema, field or app not found. /// /// - /// A disabled field cannot not be edited in the squidex portal anymore, - /// but will be part of the API response. + /// A disabled field cannot not be edited in the squidex portal anymore, but will be part of the API response. /// [HttpPut] [Route("apps/{app}/schemas/{name}/fields/{id:long}/enable/")] @@ -217,9 +354,33 @@ namespace Squidex.Areas.Api.Controllers.Schemas [ApiCosts(1)] public async Task EnableField(string app, string name, long id) { - var command = new EnableField { FieldId = id }; + await CommandBus.PublishAsync(new EnableField { FieldId = id }); - await CommandBus.PublishAsync(command); + return NoContent(); + } + + /// + /// Enable a nested schema field. + /// + /// The name of the app. + /// The name of the schema. + /// The parent field id. + /// The id of the field to enable. + /// + /// 204 => Schema field enabled. + /// 400 => Schema field already enabled. + /// 404 => Schema, field or app not found. + /// + /// + /// A disabled field cannot not be edited in the squidex portal anymore, but will be part of the API response. + /// + [HttpPut] + [Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/{id:long}/enable/")] + [ProducesResponseType(typeof(ErrorDto), 400)] + [ApiCosts(1)] + public async Task EnableNestedField(string app, string name, long parentId, long id) + { + await CommandBus.PublishAsync(new EnableField { ParentFieldId = parentId, FieldId = id }); return NoContent(); } @@ -236,8 +397,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// 404 => Schema, field or app not found. /// /// - /// A disabled field cannot not be edited in the squidex portal anymore, - /// but will be part of the API response. + /// A disabled field cannot not be edited in the squidex portal anymore, but will be part of the API response. /// [HttpPut] [Route("apps/{app}/schemas/{name}/fields/{id:long}/disable/")] @@ -245,9 +405,33 @@ namespace Squidex.Areas.Api.Controllers.Schemas [ApiCosts(1)] public async Task DisableField(string app, string name, long id) { - var command = new DisableField { FieldId = id }; + await CommandBus.PublishAsync(new DisableField { FieldId = id }); - await CommandBus.PublishAsync(command); + return NoContent(); + } + + /// + /// Disable nested a schema field. + /// + /// The name of the app. + /// The name of the schema. + /// The parent field id. + /// The id of the field to disable. + /// + /// 204 => Schema field disabled. + /// 400 => Schema field already disabled. + /// 404 => Schema, field or app not found. + /// + /// + /// A disabled field cannot not be edited in the squidex portal anymore, but will be part of the API response. + /// + [HttpPut] + [Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/{id:long}/disable/")] + [ProducesResponseType(typeof(ErrorDto), 400)] + [ApiCosts(1)] + public async Task DisableNestedField(string app, string name, long parentId, long id) + { + await CommandBus.PublishAsync(new DisableField { ParentFieldId = parentId, FieldId = id }); return NoContent(); } @@ -268,9 +452,29 @@ namespace Squidex.Areas.Api.Controllers.Schemas [ApiCosts(1)] public async Task DeleteField(string app, string name, long id) { - var command = new DeleteField { FieldId = id }; + await CommandBus.PublishAsync(new DeleteField { FieldId = id }); - await CommandBus.PublishAsync(command); + return NoContent(); + } + + /// + /// Delete a nested schema field. + /// + /// The name of the app. + /// The name of the schema. + /// The parent field id. + /// The id of the field to disable. + /// + /// 204 => Schema field deleted. + /// 400 => Field is locked. + /// 404 => Schema, field or app not found. + /// + [HttpDelete] + [Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/{id:long}/")] + [ApiCosts(1)] + public async Task DeleteNestedField(string app, string name, long parentId, long id) + { + await CommandBus.PublishAsync(new DeleteField { ParentFieldId = parentId, FieldId = id }); return NoContent(); } diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs b/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs index 7118cc2a3..16ad59a2d 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs @@ -11,13 +11,10 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using NSwag.Annotations; using Squidex.Areas.Api.Controllers.Schemas.Models; -using Squidex.Areas.Api.Controllers.Schemas.Models.Converters; -using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.Schemas.Commands; using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.Reflection; using Squidex.Pipeline; namespace Squidex.Areas.Api.Controllers.Schemas @@ -56,7 +53,9 @@ namespace Squidex.Areas.Api.Controllers.Schemas { var schemas = await appProvider.GetSchemasAsync(AppId); - var response = schemas.Select(s => s.ToModel()).ToList(); + var response = schemas.Select(SchemaDto.FromSchema).ToList(); + + Response.Headers["ETag"] = response.ToManyEtag(); return Ok(response); } @@ -93,7 +92,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas return NotFound(); } - var response = entity.ToDetailsModel(); + var response = SchemaDetailsDto.FromSchema(entity); Response.Headers["ETag"] = entity.Version.ToString(); @@ -120,7 +119,6 @@ namespace Squidex.Areas.Api.Controllers.Schemas public async Task PostSchema(string app, [FromBody] CreateSchemaDto request) { var command = request.ToCommand(); - var context = await CommandBus.PublishAsync(command); var result = context.Result>(); @@ -146,9 +144,29 @@ namespace Squidex.Areas.Api.Controllers.Schemas [ApiCosts(1)] public async Task PutSchema(string app, string name, [FromBody] UpdateSchemaDto request) { - var properties = SimpleMapper.Map(request, new SchemaProperties()); + await CommandBus.PublishAsync(request.ToCommand()); + + return NoContent(); + } - await CommandBus.PublishAsync(new UpdateSchema { Properties = properties }); + /// + /// Update a schema category. + /// + /// The name of the app. + /// The name of the schema. + /// The schema object that needs to updated. + /// + /// 204 => Schema has been updated. + /// 400 => Schema properties are not valid. + /// 404 => Schema or app not found. + /// + [MustBeAppDeveloper] + [HttpPut] + [Route("apps/{app}/schemas/{name}/category")] + [ApiCosts(1)] + public async Task PutCategory(string app, string name, [FromBody] ChangeCategoryDto request) + { + await CommandBus.PublishAsync(request.ToCommand()); return NoContent(); } @@ -170,9 +188,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas [ApiCosts(1)] public async Task PutSchemaScripts(string app, string name, [FromBody] ConfigureScriptsDto request) { - var command = SimpleMapper.Map(request, new ConfigureScripts()); - - await CommandBus.PublishAsync(command); + await CommandBus.PublishAsync(request.ToCommand()); return NoContent(); } diff --git a/src/Squidex/Areas/Api/Controllers/Statistics/Models/CallsUsageDto.cs b/src/Squidex/Areas/Api/Controllers/Statistics/Models/CallsUsageDto.cs index e5d2f38fb..7fcf0289d 100644 --- a/src/Squidex/Areas/Api/Controllers/Statistics/Models/CallsUsageDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Statistics/Models/CallsUsageDto.cs @@ -6,6 +6,7 @@ // ========================================================================== using System; +using Squidex.Infrastructure.UsageTracking; namespace Squidex.Areas.Api.Controllers.Statistics.Models { @@ -25,5 +26,12 @@ namespace Squidex.Areas.Api.Controllers.Statistics.Models /// The average duration in milliseconds. /// public long AverageMs { get; set; } + + public static CallsUsageDto FromUsage(DateUsage usage) + { + var averageMs = usage.TotalCount == 0 ? 0 : usage.TotalElapsedMs / usage.TotalCount; + + return new CallsUsageDto { Date = usage.Date, Count = usage.TotalCount, AverageMs = averageMs }; + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Statistics/Models/StorageUsageDto.cs b/src/Squidex/Areas/Api/Controllers/Statistics/Models/StorageUsageDto.cs index f16578c5f..dac9e6f35 100644 --- a/src/Squidex/Areas/Api/Controllers/Statistics/Models/StorageUsageDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Statistics/Models/StorageUsageDto.cs @@ -6,6 +6,7 @@ // ========================================================================== using System; +using Squidex.Domain.Apps.Entities.Assets; namespace Squidex.Areas.Api.Controllers.Statistics.Models { @@ -25,5 +26,10 @@ namespace Squidex.Areas.Api.Controllers.Statistics.Models /// The size in bytes. /// public long Size { get; set; } + + public static StorageUsageDto FromStats(IAssetStatsEntity stats) + { + return new StorageUsageDto { Date = stats.Date, Count = stats.TotalCount, Size = stats.TotalSize }; + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs b/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs index 380ed2365..30eff2da8 100644 --- a/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs +++ b/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs @@ -6,6 +6,7 @@ // ========================================================================== using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; @@ -60,11 +61,13 @@ namespace Squidex.Areas.Api.Controllers.Statistics [ApiCosts(0)] public async Task GetMonthlyCalls(string app) { - var count = await usageTracker.GetMonthlyCalls(App.Id.ToString(), DateTime.Today); + var count = await usageTracker.GetMonthlyCallsAsync(App.Id.ToString(), DateTime.Today); var plan = appPlanProvider.GetPlanForApp(App); - return Ok(new CurrentCallsDto { Count = count, MaxAllowed = plan.MaxApiCalls }); + var response = new CurrentCallsDto { Count = count, MaxAllowed = plan.MaxApiCalls }; + + return Ok(response); } /// @@ -80,7 +83,7 @@ namespace Squidex.Areas.Api.Controllers.Statistics /// [HttpGet] [Route("apps/{app}/usages/calls/{fromDate}/{toDate}/")] - [ProducesResponseType(typeof(CallsUsageDto[]), 200)] + [ProducesResponseType(typeof(Dictionary), 200)] [ApiCosts(0)] public async Task GetUsages(string app, DateTime fromDate, DateTime toDate) { @@ -91,14 +94,9 @@ namespace Squidex.Areas.Api.Controllers.Statistics var entities = await usageTracker.QueryAsync(App.Id.ToString(), fromDate.Date, toDate.Date); - var models = entities.Select(x => - { - var averageMs = x.TotalCount == 0 ? 0 : x.TotalElapsedMs / x.TotalCount; - - return new CallsUsageDto { Date = x.Date, Count = x.TotalCount, AverageMs = averageMs }; - }).ToList(); + var response = entities.ToDictionary(x => x.Key, x => x.Value.Select(CallsUsageDto.FromUsage).ToList()); - return Ok(models); + return Ok(response); } /// @@ -119,7 +117,9 @@ namespace Squidex.Areas.Api.Controllers.Statistics var plan = appPlanProvider.GetPlanForApp(App); - return Ok(new CurrentStorageDto { Size = size, MaxAllowed = plan.MaxAssetSize }); + var response = new CurrentStorageDto { Size = size, MaxAllowed = plan.MaxAssetSize }; + + return Ok(response); } /// @@ -130,8 +130,8 @@ namespace Squidex.Areas.Api.Controllers.Statistics /// The to date. /// /// 200 => Storage usage returned. - /// 404 => App not found. /// 400 => Range between from date and to date is not valid or has more than 100 days. + /// 404 => App not found. /// [HttpGet] [Route("apps/{app}/usages/storage/{fromDate}/{toDate}/")] @@ -146,7 +146,7 @@ namespace Squidex.Areas.Api.Controllers.Statistics var entities = await assetStatsRepository.QueryAsync(App.Id, fromDate.Date, toDate.Date); - var models = entities.Select(x => new StorageUsageDto { Date = x.Date, Count = x.TotalCount, Size = x.TotalSize }).ToList(); + var models = entities.Select(StorageUsageDto.FromStats).ToList(); return Ok(models); } diff --git a/src/Squidex/Areas/Api/Controllers/UI/Models/UISettingsDto.cs b/src/Squidex/Areas/Api/Controllers/UI/Models/UISettingsDto.cs index c2770c86b..a78188b4e 100644 --- a/src/Squidex/Areas/Api/Controllers/UI/Models/UISettingsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/UI/Models/UISettingsDto.cs @@ -22,5 +22,10 @@ namespace Squidex.Areas.Api.Controllers.UI.Models /// [Required] public string MapKey { get; set; } + + /// + /// Indicates whether twitter actions are supported. + /// + public bool SupportsTwitterActions { get; set; } } } diff --git a/src/Squidex/Areas/Api/Controllers/UI/Models/UpdateSettingDto.cs b/src/Squidex/Areas/Api/Controllers/UI/Models/UpdateSettingDto.cs new file mode 100644 index 000000000..8262de435 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/UI/Models/UpdateSettingDto.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Newtonsoft.Json.Linq; + +namespace Squidex.Areas.Api.Controllers.UI.Models +{ + public sealed class UpdateSettingDto + { + /// + /// The value for the setting. + /// + public JToken Value { get; set; } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/UI/UIController.cs b/src/Squidex/Areas/Api/Controllers/UI/UIController.cs index 2c87e8624..b15859cfc 100644 --- a/src/Squidex/Areas/Api/Controllers/UI/UIController.cs +++ b/src/Squidex/Areas/Api/Controllers/UI/UIController.cs @@ -5,11 +5,15 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using NSwag.Annotations; +using Orleans; using Squidex.Areas.Api.Controllers.UI.Models; using Squidex.Config; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Extensions.Actions.Twitter; using Squidex.Infrastructure.Commands; using Squidex.Pipeline; @@ -18,34 +22,87 @@ namespace Squidex.Areas.Api.Controllers.UI /// /// Manages ui settings and configs. /// + [ApiAuthorize] [ApiExceptionFilter] + [AppApi] [SwaggerTag(nameof(UI))] public sealed class UIController : ApiController { private readonly MyUIOptions uiOptions; + private readonly TwitterOptions twitterOptions; + private readonly IGrainFactory grainFactory; - public UIController(ICommandBus commandBus, IOptions uiOptions) + public UIController(ICommandBus commandBus, + IOptions uiOptions, + IOptions twitterOptions, + IGrainFactory grainFactory) : base(commandBus) { this.uiOptions = uiOptions.Value; + this.grainFactory = grainFactory; + this.twitterOptions = twitterOptions.Value; } /// /// Get ui settings. /// + /// The name of the app. + /// + /// 200 => UI settings returned. + /// 404 => App not found. + /// [HttpGet] - [Route("ui/settings/")] + [Route("apps/{app}/ui/settings/")] [ProducesResponseType(typeof(UISettingsDto), 200)] [ApiCosts(0)] - public IActionResult GetSettings() + public async Task GetSettings(string app) { - var dto = new UISettingsDto - { - MapType = uiOptions.Map?.Type ?? "OSM", - MapKey = uiOptions.Map?.GoogleMaps?.Key - }; + var result = await grainFactory.GetGrain(App.Id).GetAsync(); - return Ok(dto); + result.Value["mapType"] = uiOptions.Map?.Type ?? "OSM"; + result.Value["mapKey"] = uiOptions.Map?.GoogleMaps?.Key; + result.Value["supportTwitterAction"] = twitterOptions.IsConfigured(); + + return Ok(result.Value); + } + + /// + /// Set ui settings. + /// + /// The name of the app. + /// The name of the setting. + /// The request with the value to update. + /// + /// 200 => UI setting set. + /// 404 => App not found. + /// + [HttpPut] + [Route("apps/{app}/ui/settings/{key}")] + [ApiCosts(0)] + public async Task PutSetting(string app, string key, [FromBody] UpdateSettingDto request) + { + await grainFactory.GetGrain(App.Id).SetAsync(key, request.Value); + + return NoContent(); + } + + /// + /// Remove ui settings. + /// + /// The name of the app. + /// The name of the setting. + /// + /// 200 => UI setting removed. + /// 404 => App not found. + /// + [HttpDelete] + [Route("apps/{app}/ui/settings/{key}")] + [ApiCosts(0)] + public async Task DeleteSetting(string app, string key) + { + await grainFactory.GetGrain(App.Id).RemoveAsync(key); + + return NoContent(); } } } diff --git a/src/Squidex/Areas/Api/Controllers/Users/Models/CreateUserDto.cs b/src/Squidex/Areas/Api/Controllers/Users/Models/CreateUserDto.cs index b67f308ef..3caaa052f 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/Models/CreateUserDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/Models/CreateUserDto.cs @@ -11,13 +11,22 @@ namespace Squidex.Areas.Api.Controllers.Users.Models { public sealed class CreateUserDto { + /// + /// The email of the user. Unique value. + /// [Required] [EmailAddress] public string Email { get; set; } + /// + /// The display name (usually first name and last name) of the user. + /// [Required] public string DisplayName { get; set; } + /// + /// The password of the user. + /// [Required] public string Password { get; set; } } diff --git a/src/Squidex/Areas/Api/Controllers/Users/Models/PublicUserDto.cs b/src/Squidex/Areas/Api/Controllers/Users/Models/PublicUserDto.cs new file mode 100644 index 000000000..e398c88be --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Users/Models/PublicUserDto.cs @@ -0,0 +1,26 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; + +namespace Squidex.Areas.Api.Controllers.Users.Models +{ + public sealed class PublicUserDto + { + /// + /// The id of the user. + /// + [Required] + public string Id { get; set; } + + /// + /// The display name (usually first name and last name) of the user. + /// + [Required] + public string DisplayName { get; set; } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Users/Models/UpdateUserDto.cs b/src/Squidex/Areas/Api/Controllers/Users/Models/UpdateUserDto.cs index 049d62d47..e9511e2be 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/Models/UpdateUserDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/Models/UpdateUserDto.cs @@ -11,13 +11,22 @@ namespace Squidex.Areas.Api.Controllers.Users.Models { public sealed class UpdateUserDto { + /// + /// The email of the user. Unique value. + /// [Required] [EmailAddress] public string Email { get; set; } + /// + /// The display name (usually first name and last name) of the user. + /// [Required] public string DisplayName { get; set; } + /// + /// The password of the user. + /// public string Password { get; set; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Users/Models/UserCreatedDto.cs b/src/Squidex/Areas/Api/Controllers/Users/Models/UserCreatedDto.cs index a4134357f..815c7b533 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/Models/UserCreatedDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/Models/UserCreatedDto.cs @@ -11,10 +11,10 @@ namespace Squidex.Areas.Api.Controllers.Users.Models { public sealed class UserCreatedDto { + /// + /// The id of the user. + /// [Required] public string Id { get; set; } - - [Required] - public string PictureUrl { get; set; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Users/Models/UserDto.cs b/src/Squidex/Areas/Api/Controllers/Users/Models/UserDto.cs index c210416ef..8dc17e028 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/Models/UserDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/Models/UserDto.cs @@ -6,6 +6,8 @@ // ========================================================================== using System.ComponentModel.DataAnnotations; +using Squidex.Infrastructure.Reflection; +using Squidex.Shared.Users; namespace Squidex.Areas.Api.Controllers.Users.Models { @@ -23,12 +25,6 @@ namespace Squidex.Areas.Api.Controllers.Users.Models [Required] public string Email { get; set; } - /// - /// The url to the profile picture of the user. - /// - [Required] - public string PictureUrl { get; set; } - /// /// The display name (usually first name and last name) of the user. /// @@ -40,5 +36,10 @@ namespace Squidex.Areas.Api.Controllers.Users.Models /// [Required] public bool IsLocked { get; set; } + + public static UserDto FromUser(IUser user) + { + return SimpleMapper.Map(user, new UserDto { DisplayName = user.DisplayName() }); + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs b/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs index d77397666..32bed12c7 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs @@ -15,7 +15,6 @@ using Squidex.Areas.Api.Controllers.Users.Models; using Squidex.Domain.Users; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Security; using Squidex.Pipeline; using Squidex.Shared.Users; @@ -24,7 +23,7 @@ namespace Squidex.Areas.Api.Controllers.Users { [ApiAuthorize] [ApiExceptionFilter] - [ApiModelValidation] + [ApiModelValidation(true)] [MustBeAdministrator] [SwaggerIgnore] public sealed class UserManagementController : ApiController @@ -52,7 +51,7 @@ namespace Squidex.Areas.Api.Controllers.Users var response = new UsersDto { Total = taskForCount.Result, - Items = taskForItems.Result.Select(Map).ToArray() + Items = taskForItems.Result.Select(UserDto.FromUser).ToArray() }; return Ok(response); @@ -70,7 +69,7 @@ namespace Squidex.Areas.Api.Controllers.Users return NotFound(); } - var response = Map(entity); + var response = UserDto.FromUser(entity); return Ok(response); } @@ -82,7 +81,7 @@ namespace Squidex.Areas.Api.Controllers.Users { var user = await userManager.CreateAsync(userFactory, request.Email, request.DisplayName, request.Password); - var response = new UserCreatedDto { Id = user.Id, PictureUrl = user.PictureUrl() }; + var response = new UserCreatedDto { Id = user.Id }; return Ok(response); } @@ -127,11 +126,6 @@ namespace Squidex.Areas.Api.Controllers.Users return NoContent(); } - private static UserDto Map(IUser user) - { - return SimpleMapper.Map(user, new UserDto { DisplayName = user.DisplayName(), PictureUrl = user.PictureUrl() }); - } - private bool IsSelf(string id) { var subject = User.OpenIdSubject(); diff --git a/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs b/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs index a69b126f3..607f2047c 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs @@ -5,18 +5,18 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.IO; using System.Linq; using System.Net.Http; using System.Reflection; using System.Threading.Tasks; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using NSwag.Annotations; using Squidex.Areas.Api.Controllers.Users.Models; using Squidex.Domain.Users; using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.Reflection; +using Squidex.Infrastructure.Log; using Squidex.Pipeline; using Squidex.Shared.Users; @@ -30,8 +30,9 @@ namespace Squidex.Areas.Api.Controllers.Users public sealed class UsersController : ApiController { private static readonly byte[] AvatarBytes; - private readonly UserManager userManager; private readonly IUserPictureStore userPictureStore; + private readonly IUserResolver userResolver; + private readonly ISemanticLog log; static UsersController() { @@ -45,11 +46,17 @@ namespace Squidex.Areas.Api.Controllers.Users } } - public UsersController(ICommandBus commandBus, UserManager userManager, IUserPictureStore userPictureStore) + public UsersController( + ICommandBus commandBus, + IUserPictureStore userPictureStore, + IUserResolver userResolver, + ISemanticLog log) : base(commandBus) { - this.userManager = userManager; this.userPictureStore = userPictureStore; + this.userResolver = userResolver; + + this.log = log; } /// @@ -65,14 +72,25 @@ namespace Squidex.Areas.Api.Controllers.Users [ApiAuthorize] [HttpGet] [Route("users/")] - [ProducesResponseType(typeof(UserDto[]), 200)] + [ProducesResponseType(typeof(PublicUserDto[]), 200)] public async Task GetUsers(string query) { - var entities = await userManager.QueryByEmailAsync(query ?? string.Empty); + try + { + var entities = await userResolver.QueryByEmailAsync(query); + + var models = entities.Where(x => !x.IsHidden()).Select(UserDto.FromUser).ToArray(); - var models = entities.Select(x => SimpleMapper.Map(x, new UserDto { DisplayName = x.DisplayName(), PictureUrl = x.PictureUrl() })).ToArray(); + return Ok(models); + } + catch (Exception ex) + { + log.LogError(ex, w => w + .WriteProperty("action", nameof(GetUsers)) + .WriteProperty("status", "Failed")); + } - return Ok(models); + return Ok(new UserDto[0]); } /// @@ -86,19 +104,28 @@ namespace Squidex.Areas.Api.Controllers.Users [ApiAuthorize] [HttpGet] [Route("users/{id}/")] - [ProducesResponseType(typeof(UserDto), 200)] + [ProducesResponseType(typeof(PublicUserDto), 200)] public async Task GetUser(string id) { - var entity = await userManager.FindByIdAsync(id); + try + { + var entity = await userResolver.FindByIdOrEmailAsync(id); + + if (entity != null) + { + var response = UserDto.FromUser(entity); - if (entity == null) + return Ok(response); + } + } + catch (Exception ex) { - return NotFound(); + log.LogError(ex, w => w + .WriteProperty("action", nameof(GetUser)) + .WriteProperty("status", "Failed")); } - var response = SimpleMapper.Map(entity, new UserDto { DisplayName = entity.DisplayName(), PictureUrl = entity.PictureUrl() }); - - return Ok(response); + return NotFound(); } /// @@ -112,43 +139,44 @@ namespace Squidex.Areas.Api.Controllers.Users [HttpGet] [Route("users/{id}/picture/")] [ProducesResponseType(200)] + [ResponseCache(Duration = 3600)] public async Task GetUserPicture(string id) { - var entity = await userManager.FindByIdAsync(id); - - if (entity == null) - { - return NotFound(); - } - try { - if (entity.IsPictureUrlStored()) - { - return new FileStreamResult(await userPictureStore.DownloadAsync(entity.Id), "image/png"); - } - } - catch - { - return new FileStreamResult(new MemoryStream(AvatarBytes), "image/png"); - } + var entity = await userResolver.FindByIdOrEmailAsync(id); - using (var client = new HttpClient()) - { - var url = entity.PictureNormalizedUrl(); - - if (!string.IsNullOrWhiteSpace(url)) + if (entity != null) { - var response = await client.GetAsync(url); + if (entity.IsPictureUrlStored()) + { + return new FileStreamResult(await userPictureStore.DownloadAsync(entity.Id), "image/png"); + } - if (response.IsSuccessStatusCode) + using (var client = new HttpClient()) { - var contentType = response.Content.Headers.ContentType.ToString(); + var url = entity.PictureNormalizedUrl(); + + if (!string.IsNullOrWhiteSpace(url)) + { + var response = await client.GetAsync(url); - return new FileStreamResult(await response.Content.ReadAsStreamAsync(), contentType); + if (response.IsSuccessStatusCode) + { + var contentType = response.Content.Headers.ContentType.ToString(); + + return new FileStreamResult(await response.Content.ReadAsStreamAsync(), contentType); + } + } } } } + catch (Exception ex) + { + log.LogError(ex, w => w + .WriteProperty("action", nameof(GetUser)) + .WriteProperty("status", "Failed")); + } return new FileStreamResult(new MemoryStream(AvatarBytes), "image/png"); } diff --git a/src/Squidex/Areas/Api/Views/Shared/Docs.cshtml b/src/Squidex/Areas/Api/Views/Shared/Docs.cshtml index 889bad8fc..8e733da57 100644 --- a/src/Squidex/Areas/Api/Views/Shared/Docs.cshtml +++ b/src/Squidex/Areas/Api/Views/Shared/Docs.cshtml @@ -19,6 +19,6 @@ - + \ No newline at end of file diff --git a/src/Squidex/Areas/Frontend/Middlewares/WebpackMiddleware.cs b/src/Squidex/Areas/Frontend/Middlewares/WebpackMiddleware.cs index cceb3a4b7..4d145f1fa 100644 --- a/src/Squidex/Areas/Frontend/Middlewares/WebpackMiddleware.cs +++ b/src/Squidex/Areas/Frontend/Middlewares/WebpackMiddleware.cs @@ -27,18 +27,18 @@ namespace Squidex.Areas.Frontend.Middlewares public async Task Invoke(HttpContext context) { - var buffer = new MemoryStream(); - var body = context.Response.Body; + var responseBuffer = new MemoryStream(); + var responseBody = context.Response.Body; - context.Response.Body = buffer; + context.Response.Body = responseBuffer; await next(context); - buffer.Seek(0, SeekOrigin.Begin); + responseBuffer.Seek(0, SeekOrigin.Begin); if (context.Response.StatusCode == 200 && IsIndex(context) && IsHtml(context)) { - using (var reader = new StreamReader(buffer)) + using (var reader = new StreamReader(responseBuffer)) { var response = await reader.ReadToEndAsync(); @@ -56,17 +56,17 @@ namespace Squidex.Areas.Frontend.Middlewares context.Response.Headers["Content-Length"] = memoryStream.Length.ToString(); - await memoryStream.CopyToAsync(body); + await memoryStream.CopyToAsync(responseBody); } } } } else if (context.Response.StatusCode != 304) { - await buffer.CopyToAsync(body); + await responseBuffer.CopyToAsync(responseBody); } - context.Response.Body = body; + context.Response.Body = responseBody; } private static string InjectStyles(string response) diff --git a/src/Squidex/Areas/IdentityServer/Config/IdentityServerExtensions.cs b/src/Squidex/Areas/IdentityServer/Config/IdentityServerExtensions.cs index 721c3f1d0..2686d027e 100644 --- a/src/Squidex/Areas/IdentityServer/Config/IdentityServerExtensions.cs +++ b/src/Squidex/Areas/IdentityServer/Config/IdentityServerExtensions.cs @@ -32,9 +32,16 @@ namespace Squidex.Areas.IdentityServer.Config public static IServiceProvider UseMyAdminRole(this IServiceProvider services) { var roleManager = services.GetRequiredService>(); - var roleFactory = services.GetRequiredService(); - roleManager.CreateAsync(roleFactory.Create(SquidexRoles.Administrator)).Wait(); + Task.Run(async () => + { + if (!await roleManager.RoleExistsAsync(SquidexRoles.Administrator)) + { + var role = services.GetRequiredService().Create(SquidexRoles.Administrator); + + await roleManager.CreateAsync(role); + } + }).Wait(); return services; } diff --git a/src/Squidex/Areas/IdentityServer/Config/IdentityServerServices.cs b/src/Squidex/Areas/IdentityServer/Config/IdentityServerServices.cs index 3d3f3258e..439522fde 100644 --- a/src/Squidex/Areas/IdentityServer/Config/IdentityServerServices.cs +++ b/src/Squidex/Areas/IdentityServer/Config/IdentityServerServices.cs @@ -57,6 +57,8 @@ namespace Squidex.Areas.IdentityServer.Config services.AddIdentity() .AddDefaultTokenProviders(); + services.AddSingleton, + PwnedPasswordValidator>(); services.AddSingleton, UserClaimsPrincipalFactoryWithEmail>(); services.AddSingleton { new Secret(Constants.InternalClientSecret) }, RedirectUris = new List { - urlsOptions.BuildUrl($"{Constants.PortalPrefix}/signin-oidc", false) + urlsOptions.BuildUrl($"{Constants.PortalPrefix}/signin-oidc", false), + urlsOptions.BuildUrl($"{Constants.OrleansPrefix}/signin-oidc", false) }, AccessTokenLifetime = (int)TimeSpan.FromDays(30).TotalSeconds, AllowedGrantTypes = GrantTypes.ImplicitAndClientCredentials, diff --git a/src/Squidex/Areas/IdentityServer/Controllers/Account/AccountController.cs b/src/Squidex/Areas/IdentityServer/Controllers/Account/AccountController.cs index 4ee48c856..9bf58aabf 100644 --- a/src/Squidex/Areas/IdentityServer/Controllers/Account/AccountController.cs +++ b/src/Squidex/Areas/IdentityServer/Controllers/Account/AccountController.cs @@ -123,6 +123,8 @@ namespace Squidex.Areas.IdentityServer.Controllers.Account await userManager.UpdateAsync(user); + userEvents.OnConsentGiven(user); + return RedirectToReturnUrl(returnUrl); } diff --git a/src/Squidex/Areas/IdentityServer/Controllers/Profile/ChangeProfileModel.cs b/src/Squidex/Areas/IdentityServer/Controllers/Profile/ChangeProfileModel.cs index cb473d902..e1eb3c23e 100644 --- a/src/Squidex/Areas/IdentityServer/Controllers/Profile/ChangeProfileModel.cs +++ b/src/Squidex/Areas/IdentityServer/Controllers/Profile/ChangeProfileModel.cs @@ -17,5 +17,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile [Required(ErrorMessage = "DisplayName is required.")] public string DisplayName { get; set; } + + public bool IsHidden { get; set; } } } diff --git a/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs b/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs index 90ea54e4f..7d242ccbb 100644 --- a/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs +++ b/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs @@ -83,7 +83,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile [Route("/account/profile/update/")] public Task UpdateProfile(ChangeProfileModel model) { - return MakeChangeAsync(user => userManager.UpdateAsync(user, model.Email, model.DisplayName), + return MakeChangeAsync(user => userManager.UpdateAsync(user, model.Email, model.DisplayName, model.IsHidden), "Account updated successfully."); } @@ -195,6 +195,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile ExternalLogins = user.Logins, ExternalProviders = externalProviders, DisplayName = user.DisplayName(), + IsHidden = user.IsHidden(), HasPassword = await userManager.HasPasswordAsync(user), HasPasswordAuth = identityOptions.Value.AllowPasswordAuth, SuccessMessage = successMessage diff --git a/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileVM.cs b/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileVM.cs index 052cc9bc9..0ac09080c 100644 --- a/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileVM.cs +++ b/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileVM.cs @@ -22,6 +22,8 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile public string SuccessMessage { get; set; } + public bool IsHidden { get; set; } + public bool HasPassword { get; set; } public bool HasPasswordAuth { get; set; } diff --git a/src/Squidex/Areas/IdentityServer/Views/Account/Consent.cshtml b/src/Squidex/Areas/IdentityServer/Views/Account/Consent.cshtml index 7e6a94ae2..e5875696e 100644 --- a/src/Squidex/Areas/IdentityServer/Views/Account/Consent.cshtml +++ b/src/Squidex/Areas/IdentityServer/Views/Account/Consent.cshtml @@ -18,71 +18,77 @@

We need your consent

-
-
-

Automated E-Mails (Optional)

+
public long Version { get; set; } + + public static EntityCreatedDto FromResult(EntityCreatedResult result) + { + return new EntityCreatedDto { Id = result.IdOrValue?.ToString(), Version = result.Version }; + } } } diff --git a/src/Squidex/Pipeline/FileCallbackResultExecutor.cs b/src/Squidex/Pipeline/FileCallbackResultExecutor.cs index d4b5e3909..72d1d2ef8 100644 --- a/src/Squidex/Pipeline/FileCallbackResultExecutor.cs +++ b/src/Squidex/Pipeline/FileCallbackResultExecutor.cs @@ -8,7 +8,7 @@ using System; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Internal; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.Logging; namespace Squidex.Pipeline @@ -24,7 +24,7 @@ namespace Squidex.Pipeline { try { - SetHeadersAndLog(context, result, null); + SetHeadersAndLog(context, result, null, false); await result.Callback(context.HttpContext.Response.Body); } diff --git a/src/Squidex/Pipeline/GraphQLUrlGenerator.cs b/src/Squidex/Pipeline/GraphQLUrlGenerator.cs deleted file mode 100644 index 2908dc215..000000000 --- a/src/Squidex/Pipeline/GraphQLUrlGenerator.cs +++ /dev/null @@ -1,59 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Microsoft.Extensions.Options; -using Squidex.Config; -using Squidex.Domain.Apps.Entities.Apps; -using Squidex.Domain.Apps.Entities.Assets; -using Squidex.Domain.Apps.Entities.Contents; -using Squidex.Domain.Apps.Entities.Contents.GraphQL; -using Squidex.Domain.Apps.Entities.Schemas; -using Squidex.Infrastructure.Assets; - -namespace Squidex.Pipeline -{ - public sealed class GraphQLUrlGenerator : IGraphQLUrlGenerator - { - private readonly IAssetStore assetStore; - private readonly MyUrlsOptions urlsOptions; - - public bool CanGenerateAssetSourceUrl { get; } - - public GraphQLUrlGenerator(IOptions urlsOptions, IAssetStore assetStore, bool allowAssetSourceUrl) - { - this.assetStore = assetStore; - this.urlsOptions = urlsOptions.Value; - - CanGenerateAssetSourceUrl = allowAssetSourceUrl; - } - - public string GenerateAssetThumbnailUrl(IAppEntity app, IAssetEntity asset) - { - if (!asset.IsImage) - { - return null; - } - - return urlsOptions.BuildUrl($"api/assets/{asset.Id}?version={asset.Version}&width=100&mode=Max"); - } - - public string GenerateAssetUrl(IAppEntity app, IAssetEntity asset) - { - return urlsOptions.BuildUrl($"api/assets/{asset.Id}?version={asset.Version}"); - } - - public string GenerateContentUrl(IAppEntity app, ISchemaEntity schema, IContentEntity content) - { - return urlsOptions.BuildUrl($"api/content/{app.Name}/{schema.Name}/{content.Id}"); - } - - public string GenerateAssetSourceUrl(IAppEntity app, IAssetEntity asset) - { - return assetStore.GenerateSourceUrl(asset.Id.ToString(), asset.FileVersion, null); - } - } -} diff --git a/src/Squidex/Pipeline/IGenerateEtag.cs b/src/Squidex/Pipeline/IGenerateEtag.cs new file mode 100644 index 000000000..8b874ed62 --- /dev/null +++ b/src/Squidex/Pipeline/IGenerateEtag.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; + +namespace Squidex.Pipeline +{ + public interface IGenerateEtag + { + Guid Id { get; } + + long Version { get; } + } +} diff --git a/src/Squidex/Pipeline/LocalCacheMiddleware.cs b/src/Squidex/Pipeline/LocalCacheMiddleware.cs new file mode 100644 index 000000000..064e6a520 --- /dev/null +++ b/src/Squidex/Pipeline/LocalCacheMiddleware.cs @@ -0,0 +1,34 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Caching; + +namespace Squidex.Pipeline +{ + public sealed class LocalCacheMiddleware : IMiddleware + { + private readonly ILocalCache localCache; + + public LocalCacheMiddleware(ILocalCache localCache) + { + Guard.NotNull(localCache, nameof(localCache)); + + this.localCache = localCache; + } + + public async Task InvokeAsync(HttpContext context, RequestDelegate next) + { + using (localCache.StartContext()) + { + await next(context); + } + } + } +} diff --git a/src/Squidex/Pipeline/LogPerformanceMiddleware.cs b/src/Squidex/Pipeline/LogPerformanceMiddleware.cs deleted file mode 100644 index 166e18b51..000000000 --- a/src/Squidex/Pipeline/LogPerformanceMiddleware.cs +++ /dev/null @@ -1,38 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Diagnostics; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.Filters; -using Squidex.Infrastructure.Log; - -namespace Squidex.Pipeline -{ - public sealed class LogPerformanceMiddleware : ActionFilterAttribute - { - private readonly RequestDelegate next; - private readonly ISemanticLog log; - - public LogPerformanceMiddleware(RequestDelegate next, ISemanticLog log) - { - this.next = next; - this.log = log; - } - - public async Task Invoke(HttpContext context) - { - var stopWatch = Stopwatch.StartNew(); - - await next(context); - - stopWatch.Stop(); - - log.LogInformation(w => w.WriteProperty("elapsedRequestMs", stopWatch.ElapsedMilliseconds)); - } - } -} diff --git a/src/Squidex/Pipeline/RequestLogPerformanceMiddleware.cs b/src/Squidex/Pipeline/RequestLogPerformanceMiddleware.cs new file mode 100644 index 000000000..19e706467 --- /dev/null +++ b/src/Squidex/Pipeline/RequestLogPerformanceMiddleware.cs @@ -0,0 +1,48 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Log; + +namespace Squidex.Pipeline +{ + public sealed class RequestLogPerformanceMiddleware : IMiddleware + { + private readonly ISemanticLog log; + + public RequestLogPerformanceMiddleware(ISemanticLog log) + { + this.log = log; + } + + public async Task InvokeAsync(HttpContext context, RequestDelegate next) + { + var watch = ValueStopwatch.StartNew(); + + using (Profiler.StartSession()) + { + try + { + await next(context); + } + finally + { + var elapsedMs = watch.Stop(); + + log.LogInformation(w => + { + Profiler.Session?.Write(w); + + w.WriteProperty("elapsedRequestMs", elapsedMs); + }); + } + } + } + } +} diff --git a/src/Squidex/Pipeline/Swagger/SwaggerHelper.cs b/src/Squidex/Pipeline/Swagger/SwaggerHelper.cs index 2e4b36aa2..e8b41562e 100644 --- a/src/Squidex/Pipeline/Swagger/SwaggerHelper.cs +++ b/src/Squidex/Pipeline/Swagger/SwaggerHelper.cs @@ -79,7 +79,7 @@ namespace Squidex.Pipeline.Swagger public static SwaggerSecurityScheme CreateOAuthSchema(MyUrlsOptions urlOptions) { - var tokenUrl = urlOptions.BuildUrl($"{Constants.IdentityServerPrefix}/connect/token"); + var tokenUrl = urlOptions.BuildUrl($"{Constants.IdentityServerPrefix}/connect/token", false); var securityDocs = LoadDocs("security"); var securityText = securityDocs.Replace("", tokenUrl); @@ -108,7 +108,7 @@ namespace Squidex.Pipeline.Swagger { var errorType = typeof(ErrorDto); - return await schemaGenerator.GenerateWithReference(errorType, Enumerable.Empty(), resolver); + return await schemaGenerator.GenerateWithReferenceAsync(errorType, Enumerable.Empty(), resolver); } public static void AddQueryParameter(this SwaggerOperation operation, string name, JsonObjectType type, string description = null) diff --git a/src/Squidex/Pipeline/UrlGenerator.cs b/src/Squidex/Pipeline/UrlGenerator.cs new file mode 100644 index 000000000..48249b15b --- /dev/null +++ b/src/Squidex/Pipeline/UrlGenerator.cs @@ -0,0 +1,67 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Microsoft.Extensions.Options; +using Squidex.Config; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Domain.Apps.Entities.Contents; +using Squidex.Domain.Apps.Entities.Contents.GraphQL; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Assets; + +namespace Squidex.Pipeline +{ + public sealed class UrlGenerator : IGraphQLUrlGenerator, IRuleUrlGenerator + { + private readonly IAssetStore assetStore; + private readonly MyUrlsOptions urlsOptions; + + public bool CanGenerateAssetSourceUrl { get; } + + public UrlGenerator(IOptions urlsOptions, IAssetStore assetStore, bool allowAssetSourceUrl) + { + this.assetStore = assetStore; + this.urlsOptions = urlsOptions.Value; + + CanGenerateAssetSourceUrl = allowAssetSourceUrl; + } + + public string GenerateAssetThumbnailUrl(IAppEntity app, IAssetEntity asset) + { + if (!asset.IsImage) + { + return null; + } + + return urlsOptions.BuildUrl($"api/assets/{asset.Id}?version={asset.Version}&width=100&mode=Max"); + } + + public string GenerateAssetUrl(IAppEntity app, IAssetEntity asset) + { + return urlsOptions.BuildUrl($"api/assets/{asset.Id}?version={asset.Version}"); + } + + public string GenerateContentUrl(IAppEntity app, ISchemaEntity schema, IContentEntity content) + { + return urlsOptions.BuildUrl($"api/content/{app.Name}/{schema.Name}/{content.Id}"); + } + + public string GenerateContentUIUrl(NamedId appId, NamedId schemaId, Guid contentId) + { + return urlsOptions.BuildUrl($"app/{appId.Name}/content/{schemaId.Name}/{contentId}/history"); + } + + public string GenerateAssetSourceUrl(IAppEntity app, IAssetEntity asset) + { + return assetStore.GenerateSourceUrl(asset.Id.ToString(), asset.FileVersion, null); + } + } +} diff --git a/src/Squidex/Program.cs b/src/Squidex/Program.cs index 0266f9c99..bcf80422b 100644 --- a/src/Squidex/Program.cs +++ b/src/Squidex/Program.cs @@ -7,6 +7,9 @@ using System.IO; using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Squidex.Config; using Squidex.Infrastructure.Log.Adapter; namespace Squidex @@ -15,21 +18,32 @@ namespace Squidex { public static void Main(string[] args) { + BuildWebHost(args).Run(); + } + + public static IWebHost BuildWebHost(string[] args) => new WebHostBuilder() .UseKestrel(k => { k.AddServerHeader = false; }) .UseContentRoot(Directory.GetCurrentDirectory()) .UseIISIntegration() .UseStartup() - .ConfigureLogging(builder => + .ConfigureLogging((hostingContext, builder) => { + builder.AddConfiguration(hostingContext.Configuration.GetSection("logging")); builder.AddSemanticLog(); + builder.AddFilter(); }) .ConfigureAppConfiguration((hostContext, builder) => { - builder.AddAppConfiguration(hostContext.HostingEnvironment.EnvironmentName, args); + builder.Sources.Clear(); + + builder.AddJsonFile("appsettings.json", true, true); + builder.AddJsonFile($"appsettings.{hostContext.HostingEnvironment.EnvironmentName}.json", true); + + builder.AddEnvironmentVariables(); + + builder.AddCommandLine(args); }) - .Build() - .Run(); - } + .Build(); } } diff --git a/src/Squidex/Squidex.csproj b/src/Squidex/Squidex.csproj index 2ebea0cc2..c6c9c710c 100644 --- a/src/Squidex/Squidex.csproj +++ b/src/Squidex/Squidex.csproj @@ -1,16 +1,21 @@  - true true - $(NoWarn);CS1591;1591;1573;1572 + $(NoWarn);CS1591;1591;1573;1572;NU1605 Squidex true - netcoreapp2.0 + netcoreapp2.1 + 2.1.1 Latest true + + full + True + + @@ -29,6 +34,7 @@ + @@ -48,42 +54,42 @@ - - - - - - + + + + + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - + - - + + + + - + - + @@ -100,5 +106,4 @@ - \ No newline at end of file diff --git a/src/Squidex/WebStartup.cs b/src/Squidex/WebStartup.cs index 7632fba3a..815d183fd 100644 --- a/src/Squidex/WebStartup.cs +++ b/src/Squidex/WebStartup.cs @@ -5,21 +5,21 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Squidex.Areas.Api; using Squidex.Areas.Frontend; using Squidex.Areas.IdentityServer; +using Squidex.Areas.OrleansDashboard; using Squidex.Areas.Portal; using Squidex.Config.Domain; +using Squidex.Config.Orleans; using Squidex.Config.Web; namespace Squidex { - public sealed class WebStartup : IStartup + public sealed class WebStartup { private readonly IConfiguration configuration; @@ -28,28 +28,28 @@ namespace Squidex this.configuration = configuration; } - public IServiceProvider ConfigureServices(IServiceCollection services) + public void ConfigureServices(IServiceCollection services) { + services.AddOrleansSilo(); services.AddAppServices(configuration); - - return services.BuildServiceProvider(); } public void Configure(IApplicationBuilder app) { app.ApplicationServices.LogConfiguration(); - app.ApplicationServices.InitializeAll(); - app.ApplicationServices.Migrate(); - app.ApplicationServices.RunAll(); + app.ApplicationServices.RunInitialization(); + app.ApplicationServices.RunMigrate(); + app.ApplicationServices.RunRunnables(); + app.UseMyLocalCache(); app.UseMyCors(); app.UseMyForwardingRules(); app.UseMyTracking(); app.ConfigureApi(); app.ConfigurePortal(); + app.ConfigureOrleansDashboard(); app.ConfigureIdentityServer(); - app.ConfigureFrontend(); } } diff --git a/src/Squidex/app-config/fix-coverage-loader.js b/src/Squidex/app-config/fix-coverage-loader.js deleted file mode 100644 index d5e5f2ff9..000000000 --- a/src/Squidex/app-config/fix-coverage-loader.js +++ /dev/null @@ -1,47 +0,0 @@ -function fixCoverage(contents) { - this.cacheable(); - - var ignores = [ - { name: 'arguments', line: 'var _a' }, - { name: 'decorate', line: 'var __decorate =', }, - { name: 'metadata', line: 'var __metadata =', }, - { name: 'extends', line: 'var __extends =', }, - { name: 'export', line: 'function __export' } - ]; - - var updates = 0; - var rows = contents.split('\n'); - - for (var rowIndex = 0; rowIndex < rows.length; rowIndex++) { - var row = rows[rowIndex].trim(); - - for (var ignoreIndex = 0; ignoreIndex < ignores.length; ignoreIndex++) { - var ignore = ignores[ignoreIndex]; - - if (row.indexOf(ignore.line) >= 0) { - rows.splice(rowIndex, 0, '/* istanbul ignore next: TypeScript ' + ignore.name + ' */'); - rowIndex++; - updates++; - break; - } - } - - if (row.indexOf('hasOwnProperty') >= 0) { - rows.splice(rowIndex, 0, '/* istanbul ignore else */'); - rowIndex++; - updates++; - } - - if (updates === ignores.length) { - break; - } - } - - if (updates > 0) { - return rows.join('\n'); - } else { - return contents; - } -} - -module.exports = fixCoverage; \ No newline at end of file diff --git a/src/Squidex/app-config/helpers.js b/src/Squidex/app-config/helpers.js index 30c3534da..acae3e4bb 100644 --- a/src/Squidex/app-config/helpers.js +++ b/src/Squidex/app-config/helpers.js @@ -1,8 +1,4 @@ -// ReSharper disable InconsistentNaming -// ReSharper disable PossiblyUnassignedProperty -// ReSharper disable InconsistentNaming - -var path = require('path'); +var path = require('path'); var appRoot = path.resolve(__dirname, '..'); diff --git a/src/Squidex/app-config/karma-test-shim.js b/src/Squidex/app-config/karma-test-shim.js index 13e285d15..cd4be9556 100644 --- a/src/Squidex/app-config/karma-test-shim.js +++ b/src/Squidex/app-config/karma-test-shim.js @@ -11,8 +11,6 @@ require('zone.js/dist/jasmine-patch'); require('zone.js/dist/async-test'); require('zone.js/dist/fake-async-test'); -require('rxjs/Rx'); - var testing = require('@angular/core/testing'); var browser = require('@angular/platform-browser-dynamic/testing'); diff --git a/src/Squidex/app-config/karma.conf.js b/src/Squidex/app-config/karma.conf.js index 852cce536..e95ed7d43 100644 --- a/src/Squidex/app-config/karma.conf.js +++ b/src/Squidex/app-config/karma.conf.js @@ -34,7 +34,7 @@ module.exports = function (config) { }, /* - * leave Jasmine Spec Runner output visible in browser + * Leave Jasmine Spec Runner output visible in browser */ client: { clearContext: false diff --git a/src/Squidex/app-config/karma.coverage.conf.js b/src/Squidex/app-config/karma.coverage.conf.js index 29ae72b39..14e55e51b 100644 --- a/src/Squidex/app-config/karma.coverage.conf.js +++ b/src/Squidex/app-config/karma.coverage.conf.js @@ -65,12 +65,20 @@ module.exports = function (config) { */ singleRun: true, + customLaunchers: { + ChromeCustom: { + base: 'ChromeHeadless', + // We must disable the Chrome sandbox (Chrome's sandbox needs more permissions than Docker allows by default) + flags: ['--no-sandbox'] + } + }, + /** * Run with chrome because phantom js does not provide all types, e.g. DragEvent * * available browser launchers: https://npmjs.org/browse/keyword/karma-launcher */ - browsers: ['PhantomJS'] + browsers: ['ChromeCustom'] }; config.set(_config); diff --git a/src/Squidex/app-config/webpack.config.js b/src/Squidex/app-config/webpack.config.js index 41b87ba34..f78e351eb 100644 --- a/src/Squidex/app-config/webpack.config.js +++ b/src/Squidex/app-config/webpack.config.js @@ -1,11 +1,13 @@ -// ReSharper disable InconsistentNaming -// ReSharper disable PossiblyUnassignedProperty +const webpack = require('webpack'), + path = require('path'), + helpers = require('./helpers'); - var webpack = require('webpack'), - path = require('path'), - HtmlWebpackPlugin = require('html-webpack-plugin'), - ExtractTextPlugin = require('extract-text-webpack-plugin'), - helpers = require('./helpers'); +const plugins = { + // https://github.com/webpack-contrib/mini-css-extract-plugin + MiniCssExtractPlugin: require('mini-css-extract-plugin'), + // https://github.com/dividab/tsconfig-paths-webpack-plugin + TsconfigPathsPlugin: require('tsconfig-paths-webpack-plugin') +}; module.exports = { /** @@ -19,13 +21,16 @@ module.exports = { * * See: https://webpack.js.org/configuration/resolve/#resolve-extensions */ - extensions: ['.js', '.ts', '.css', '.scss'], + extensions: ['.js', '.mjs', '.ts', '.css', '.scss'], modules: [ helpers.root('app'), helpers.root('app', 'theme'), - helpers.root('app-libs'), helpers.root('node_modules') ], + + plugins: [ + new plugins.TsconfigPathsPlugin() + ] }, /* @@ -39,70 +44,75 @@ module.exports = { * * See: https://webpack.js.org/configuration/module/#module-rules */ - rules: [ - { - test: /\.ts$/, - use: [{ - loader: 'awesome-typescript-loader' - }, { - loader: 'angular2-router-loader' - }, { - loader: 'angular2-template-loader' - }, { - loader: 'tslint-loader' - }], - exclude: /node_modules/ - }, { - test: /\.ts$/, - use: [{ - loader: 'awesome-typescript-loader' - }], - include: /node_modules/ + rules: [{ + test: /\.mjs$/, + type: "javascript/auto", + include: [/node_modules/], + },{ + test: /\.ts$/, + use: [{ + loader: 'awesome-typescript-loader' }, { - test: /\.js\.flow$/, - use: [{ - loader: 'ignore-loader' - }], - include: /node_modules/ + loader: 'angular-router-loader' }, { - test: /\.html$/, - use: [{ - loader: 'raw-loader' - }] + loader: 'angular2-template-loader' }, { - test: /\.(woff|woff2|ttf|eot)(\?.*$|$)/, - use: [{ - loader: 'file-loader?name=assets/[name].[hash].[ext]' - }] - }, { - test: /\.(png|jpe?g|gif|svg|ico)(\?.*$|$)/, - use: [{ - loader: 'file-loader?name=assets/[name].[hash].[ext]' - }] - }, { - test: /\.css$/, - /* - * Extract the content from a bundle to a file - * - * See: https://github.com/webpack-contrib/extract-text-webpack-plugin - */ - use: ExtractTextPlugin.extract({ fallback: 'style-loader', use: 'css-loader?sourceMap' }) + loader: 'tslint-loader' + }], + exclude: [/node_modules/] + }, { + test: /\.ts$/, + use: [{ + loader: 'awesome-typescript-loader' + }], + include: [/node_modules/] + }, { + test: /\.js\.flow$/, + use: [{ + loader: 'ignore-loader' + }], + include: [/node_modules/] + }, { + test: /\.html$/, + use: [{ + loader: 'raw-loader' + }] + }, { + test: /\.(woff|woff2|ttf|eot)(\?.*$|$)/, + use: [{ + loader: 'file-loader?name=assets/[name].[hash].[ext]' + }] + }, { + test: /\.(png|jpe?g|gif|svg|ico)(\?.*$|$)/, + use: [{ + loader: 'file-loader?name=assets/[name].[hash].[ext]' + }] + }, { + test: /\.css$/, + use: [ + plugins.MiniCssExtractPlugin.loader, + { + loader: 'css-loader' + }] + }, { + test: /\.scss$/, + use: [{ + loader: 'raw-loader' }, { - test: /\.scss$/, - use: [{ - loader: 'raw-loader' - }, { - loader: 'sass-loader', - options: { - includePaths: [helpers.root('app', 'theme')] - } - }], - exclude: helpers.root('app', 'theme') - } - ] + loader: 'sass-loader', options: { includePaths: [helpers.root('app', 'theme')] } + }], + exclude: helpers.root('app', 'theme') + }] }, plugins: [ + /* + * Puts each bundle into a file and appends the hash of the file to the path. + * + * See: https://github.com/webpack-contrib/mini-css-extract-plugin + */ + new plugins.MiniCssExtractPlugin('[name].css'), + new webpack.LoaderOptionsPlugin({ options: { tslint: { @@ -128,7 +138,7 @@ module.exports = { context: '/' } }), - + new webpack.ContextReplacementPlugin(/moment[\/\\]locale$/, /en/), /** diff --git a/src/Squidex/app-config/webpack.run.base.js b/src/Squidex/app-config/webpack.run.base.js index c9f4cf820..e32a69fd2 100644 --- a/src/Squidex/app-config/webpack.run.base.js +++ b/src/Squidex/app-config/webpack.run.base.js @@ -1,11 +1,12 @@ -// ReSharper disable InconsistentNaming -// ReSharper disable PossiblyUnassignedProperty +const webpack = require('webpack'), + webpackMerge = require('webpack-merge'), + path = require('path'), + helpers = require('./helpers'), + commonConfig = require('./webpack.config.js'); - var webpack = require('webpack'), - webpackMerge = require('webpack-merge'), -HtmlWebpackPlugin = require('html-webpack-plugin'), - commonConfig = require('./webpack.config.js'), - helpers = require('./helpers'); +const plugins = { + HtmlWebpackPlugin: require('html-webpack-plugin') +}; module.exports = webpackMerge(commonConfig, { /** @@ -20,28 +21,15 @@ module.exports = webpackMerge(commonConfig, { }, plugins: [ - /** - * Shares common code between the pages. - * - * See: https://webpack.js.org/plugins/commons-chunk-plugin/ - */ - new webpack.optimize.CommonsChunkPlugin({ - name: ['app', 'shims'] - }), - - /** - * Simplifies creation of HTML files to serve your webpack bundles. - * This is especially useful for webpack bundles that include a hash in the filename - * which changes every compilation. - * - * See: https://github.com/ampedandwired/html-webpack-plugin - */ - new HtmlWebpackPlugin({ - template: 'wwwroot/index.html', hash: true + new plugins.HtmlWebpackPlugin({ + hash: true, + chunks: ['shims', 'app'], + chunksSortMode: 'manual', + template: 'wwwroot/index.html' }), - new HtmlWebpackPlugin({ - template: 'wwwroot/theme.html', hash: true, filename: 'theme.html' + new plugins.HtmlWebpackPlugin({ + template: 'wwwroot/theme.html', hash: true, chunksSortMode: 'none', filename: 'theme.html' }) ] }); \ No newline at end of file diff --git a/src/Squidex/app-config/webpack.run.dev.js b/src/Squidex/app-config/webpack.run.dev.js index 224f8d515..af6ea0124 100644 --- a/src/Squidex/app-config/webpack.run.dev.js +++ b/src/Squidex/app-config/webpack.run.dev.js @@ -1,22 +1,17 @@ -// ReSharper disable InconsistentNaming -// ReSharper disable PossiblyUnassignedProperty - - var webpackMerge = require('webpack-merge'), - ExtractTextPlugin = require('extract-text-webpack-plugin'), - runConfig = require('./webpack.run.base.js'), - helpers = require('./helpers'); +const webpack = require('webpack'), + webpackMerge = require('webpack-merge'), + path = require('path'), + helpers = require('./helpers'), + runConfig = require('./webpack.run.base.js'); module.exports = webpackMerge(runConfig, { - /** - * Developer tool to enhance debugging - * - * See: https://webpack.js.org/configuration/devtool/#devtool - * See: https://webpack.js.org/guides/build-performance/ - */ - devtool: 'cheap-module-source-map', + mode: 'development', + + devtool: 'source-map', output: { filename: '[name].js', + // Set the public path, because we are running the website from another port (5000) publicPath: 'http://localhost:3000/' }, @@ -32,32 +27,23 @@ module.exports = webpackMerge(runConfig, { * * See: https://webpack.js.org/configuration/module/#module-rules */ - rules: [ - { - test: /\.scss$/, - use: [{ - loader: 'style-loader' - }, { - loader: 'css-loader' - }, { - loader: 'sass-loader?sourceMap', - options: { - includePaths: [helpers.root('app', 'theme')] - } - }], - include: helpers.root('app', 'theme') - } - ] + rules: [{ + test: /\.scss$/, + use: [{ + loader: 'style-loader' + }, { + loader: 'css-loader' + }, { + loader: 'sass-loader?sourceMap', options: { includePaths: [helpers.root('app', 'theme')] } + }], + include: helpers.root('app', 'theme') + }] }, - plugins: [ - new ExtractTextPlugin('[name].css') - ], - devServer: { - historyApiFallback: true, stats: 'minimal', headers: { 'Access-Control-Allow-Origin': '*' - } + }, + historyApiFallback: true } }); \ No newline at end of file diff --git a/src/Squidex/app-config/webpack.run.prod.js b/src/Squidex/app-config/webpack.run.prod.js index 6e1e076db..f15097cff 100644 --- a/src/Squidex/app-config/webpack.run.prod.js +++ b/src/Squidex/app-config/webpack.run.prod.js @@ -1,16 +1,22 @@ - var webpack = require('webpack'), - webpackMerge = require('webpack-merge'), -ExtractTextPlugin = require('extract-text-webpack-plugin'), - ngToolsWebpack = require('@ngtools/webpack'), - runConfig = require('./webpack.run.base.js'), - helpers = require('./helpers'); - -var ENV = process.env.NODE_ENV = process.env.ENV = 'production'; +const webpack = require('webpack'), + webpackMerge = require('webpack-merge'), + path = require('path'), + helpers = require('./helpers'), + runConfig = require('./webpack.run.base.js'); +const plugins = { + // https://github.com/mishoo/UglifyJS2/tree/harmony + UglifyJsPlugin: require('uglifyjs-webpack-plugin'), + // https://www.npmjs.com/package/@ngtools/webpack + NgToolsWebpack: require('@ngtools/webpack'), + // https://github.com/webpack-contrib/mini-css-extract-plugin + MiniCssExtractPlugin: require('mini-css-extract-plugin') +}; + helpers.removeLoaders(runConfig, ['scss', 'ts']); module.exports = webpackMerge(runConfig, { - devtool: 'source-map', + mode: 'production', output: { /** @@ -50,65 +56,62 @@ module.exports = webpackMerge(runConfig, { * * See: https://webpack.js.org/configuration/module/#module-rules */ - rules: [ + rules: [{ + test: /\.scss$/, + /* + * Extract the content from a bundle to a file + * + * See: https://github.com/webpack-contrib/extract-text-webpack-plugin + */ + use: [ + plugins.MiniCssExtractPlugin.loader, { - test: /\.scss$/, - /* - * Extract the content from a bundle to a file - * - * See: https://github.com/webpack-contrib/extract-text-webpack-plugin - */ - use: ExtractTextPlugin.extract({ fallback: 'style-loader', use: 'css-loader?minimize!sass-loader?sourceMap' }), - /* - * Do not include component styles - */ - include: helpers.root('app', 'theme'), + loader: 'css-loader', options: { minimize: true }, }, { - test: /\.scss$/, - use: [{ - loader: 'raw-loader' - }, { - loader: 'sass-loader', - options: { - includePaths: [helpers.root('app', 'theme')] - } - }], - exclude: helpers.root('app', 'theme'), - }, { - test: /\.ts/, - use: [{ - loader: '@ngtools/webpack' - }] - } - ] + loader: 'sass-loader' + }], + /* + * Do not include component styles + */ + include: helpers.root('app', 'theme'), + }, { + test: /\.scss$/, + use: [{ + loader: 'raw-loader' + }, { + loader: 'sass-loader', options: { includePaths: [helpers.root('app', 'theme')] } + }], + exclude: helpers.root('app', 'theme'), + }, { + test: /(?:\.ngfactory\.js|\.ngstyle\.js|\.ts)$/, + use: [{ + loader: '@ngtools/webpack' + }] + }] }, plugins: [ - new webpack.NoEmitOnErrorsPlugin(), - new webpack.DefinePlugin({ 'process.env': { 'ENV': JSON.stringify(ENV) } }), - new webpack.optimize.ModuleConcatenationPlugin(), + new plugins.NgToolsWebpack.AngularCompilerPlugin({ + entryModule: 'app/app.module#AppModule', + sourceMap: false, + skipSourceGeneration: false, + tsConfigPath: './tsconfig.json' + }), + ], - /* - * Puts each bundle into a file and appends the hash of the file to the path. - * - * See: https://github.com/webpack/extract-text-webpack-plugin - */ - new ExtractTextPlugin('[name].css'), - - new webpack.optimize.UglifyJsPlugin({ - beautify: false, - mangle: { - screw_ie8: true, keep_fnames: true - }, - compress: { - screw_ie8: true, warnings: false - }, - comments: false - }), + optimization: { + minimizer: [ + new plugins.UglifyJsPlugin({ + uglifyOptions: { + compress: false, + ecma: 6, + mangle: true + } + }) + ] + }, - new ngToolsWebpack.AngularCompilerPlugin({ - tsConfigPath: './tsconfig.json', - entryModule: 'app/app.module#AppModule' - }), - ] + performance: { + hints: false + } }); \ No newline at end of file diff --git a/src/Squidex/app-config/webpack.test.coverage.js b/src/Squidex/app-config/webpack.test.coverage.js index 4bfdb9654..f91362c67 100644 --- a/src/Squidex/app-config/webpack.test.coverage.js +++ b/src/Squidex/app-config/webpack.test.coverage.js @@ -1,8 +1,8 @@ - -var webpackMerge = require('webpack-merge'), - path = require('path'), - helpers = require('./helpers'), - testConfig = require('./webpack.test.js'); +const webpack = require('webpack'), + webpackMerge = require('webpack-merge'), + path = require('path'), + helpers = require('./helpers'), + testConfig = require('./webpack.test.js'); helpers.removeLoaders(testConfig, ['ts']); @@ -13,29 +13,27 @@ module.exports = webpackMerge(testConfig, { * * See: https://webpack.js.org/configuration/module/#module-rules */ - rules: [ - { - test: /\.ts$/, - use: [{ - loader: 'awesome-typescript-loader' - }], - include: [/\.(e2e|spec)\.ts$/], - + rules: [{ + test: /\.ts$/, + use: [{ + loader: 'awesome-typescript-loader' + }], + include: [/\.(e2e|spec)\.ts$/], + + }, { + test: /\.ts$/, + use: [{ + loader: 'istanbul-instrumenter-loader' + },{ + loader: 'awesome-typescript-loader' }, { - test: /\.ts$/, - use: [{ - loader: 'istanbul-instrumenter-loader' - }, { - loader: helpers.root('app-config', 'fix-coverage-loader') - }, { - loader: 'awesome-typescript-loader' - }, { - loader: 'angular2-router-loader' - }, { - loader: 'angular2-template-loader' - }], - exclude: [/\.(e2e|spec)\.ts$/] - } - ] + loader: 'angular-router-loader' + }, { + loader: 'angular2-template-loader' + }, { + loader: 'tslint-loader' + }], + exclude: [/\.(e2e|spec)\.ts$/] + }] } }); \ No newline at end of file diff --git a/src/Squidex/app-config/webpack.test.js b/src/Squidex/app-config/webpack.test.js index 04955323e..1dd89b9e3 100644 --- a/src/Squidex/app-config/webpack.test.js +++ b/src/Squidex/app-config/webpack.test.js @@ -1,14 +1,17 @@ - var webpack = require('webpack'), -webpackMerge = require('webpack-merge'), -commonConfig = require('./webpack.config.js'), - helpers = require('./helpers'); + const webpack = require('webpack'), + webpackMerge = require('webpack-merge'), + path = require('path'), + helpers = require('./helpers'), + commonConfig = require('./webpack.config.js'); module.exports = webpackMerge(commonConfig, { + mode: 'development', + /** * Source map for Karma from the help of karma-sourcemap-loader & karma-webpack * * Do not change, leave as is or it wont work. * See: https://webpack.js.org/configuration/devtool/ */ - devtool: 'inline-source-map', + devtool: 'inline-source-map' }); \ No newline at end of file diff --git a/src/Squidex/app/app.component.html b/src/Squidex/app/app.component.html index 668ebbd76..13f8dc54e 100644 --- a/src/Squidex/app/app.component.html +++ b/src/Squidex/app/app.component.html @@ -1,9 +1,13 @@ 
- -
- + + + +
+ Loading -
Loading Squidex
-
-
+
Loading Squidex
+
+
+ +
diff --git a/src/Squidex/app/app.module.ts b/src/Squidex/app/app.module.ts index 1af3da9e0..b65f53064 100644 --- a/src/Squidex/app/app.module.ts +++ b/src/Squidex/app/app.module.ts @@ -5,9 +5,13 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ +import { CommonModule } from '@angular/common'; +import { HttpClientModule } from '@angular/common/http'; import { ApplicationRef, NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { BrowserModule } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterModule } from '@angular/router'; import { DndModule } from 'ng2-dnd'; import { AppComponent } from './app.component'; @@ -56,6 +60,11 @@ export function configUserReport() { BrowserModule, BrowserAnimationsModule, DndModule.forRoot(), + HttpClientModule, + FormsModule, + CommonModule, + RouterModule, + ReactiveFormsModule, SqxFrameworkModule.forRoot(), SqxSharedModule.forRoot(), SqxShellModule, diff --git a/src/Squidex/app/app.routes.ts b/src/Squidex/app/app.routes.ts index 00674bff9..d7598968f 100644 --- a/src/Squidex/app/app.routes.ts +++ b/src/Squidex/app/app.routes.ts @@ -6,7 +6,7 @@ */ import { ModuleWithProviders } from '@angular/core'; -import { PreloadAllModules, RouterModule, Routes } from '@angular/router'; +import { RouterModule, Routes } from '@angular/router'; import { AppAreaComponent, @@ -19,6 +19,7 @@ import { import { AppMustExistGuard, + LoadAppsGuard, MustBeAuthenticatedGuard, MustBeNotAuthenticatedGuard, UnsetAppGuard @@ -33,7 +34,7 @@ export const routes: Routes = [ { path: 'app', component: InternalAreaComponent, - canActivate: [MustBeAuthenticatedGuard], + canActivate: [MustBeAuthenticatedGuard, LoadAppsGuard], children: [ { path: '', @@ -96,4 +97,4 @@ export const routes: Routes = [ } ]; -export const routing: ModuleWithProviders = RouterModule.forRoot(routes, { useHash: false, preloadingStrategy: PreloadAllModules }); \ No newline at end of file +export const routing: ModuleWithProviders = RouterModule.forRoot(routes, { useHash: false }); \ No newline at end of file diff --git a/src/Squidex/app/app.ts b/src/Squidex/app/app.ts index bb9ddbbf5..4b7765637 100644 --- a/src/Squidex/app/app.ts +++ b/src/Squidex/app/app.ts @@ -12,7 +12,7 @@ import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; import { AppModule } from './app.module'; -if (process.env.ENV === 'production') { +if (process.env.NODE_ENV === 'production') { enableProdMode(); } diff --git a/src/Squidex/app/features/administration/administration-area.component.html b/src/Squidex/app/features/administration/administration-area.component.html index e75dab4a4..6319cf409 100644 --- a/src/Squidex/app/features/administration/administration-area.component.html +++ b/src/Squidex/app/features/administration/administration-area.component.html @@ -12,6 +12,11 @@ + diff --git a/src/Squidex/app/features/administration/declarations.ts b/src/Squidex/app/features/administration/declarations.ts index 16f825083..09279fe2c 100644 --- a/src/Squidex/app/features/administration/declarations.ts +++ b/src/Squidex/app/features/administration/declarations.ts @@ -5,8 +5,18 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ +export * from './administration-area.component'; + +export * from './guards/user-must-exist.guard'; +export * from './guards/unset-user.guard'; + export * from './pages/event-consumers/event-consumers-page.component'; +export * from './pages/restore/restore-page.component'; export * from './pages/users/user-page.component'; export * from './pages/users/users-page.component'; -export * from './administration-area.component'; \ No newline at end of file +export * from './services/event-consumers.service'; +export * from './services/users.service'; + +export * from './state/event-consumers.state'; +export * from './state/users.state'; \ No newline at end of file diff --git a/src/Squidex/app/features/administration/guards/unset-user.guard.spec.ts b/src/Squidex/app/features/administration/guards/unset-user.guard.spec.ts new file mode 100644 index 000000000..15414e732 --- /dev/null +++ b/src/Squidex/app/features/administration/guards/unset-user.guard.spec.ts @@ -0,0 +1,37 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { of } from 'rxjs'; +import { IMock, Mock, Times } from 'typemoq'; + +import { UsersState } from './../state/users.state'; +import { UnsetUserGuard } from './unset-user.guard'; + +describe('UnsetUserGuard', () => { + let usersState: IMock; + let userGuard: UnsetUserGuard; + + beforeEach(() => { + usersState = Mock.ofType(); + userGuard = new UnsetUserGuard(usersState.object); + }); + + it('should unset user', () => { + usersState.setup(x => x.select(null)) + .returns(() => of(null)); + + let result: boolean; + + userGuard.canActivate().subscribe(x => { + result = x; + }).unsubscribe(); + + expect(result!).toBeTruthy(); + + usersState.verify(x => x.select(null), Times.once()); + }); +}); \ No newline at end of file diff --git a/src/Squidex/app/features/administration/guards/unset-user.guard.ts b/src/Squidex/app/features/administration/guards/unset-user.guard.ts new file mode 100644 index 000000000..1a8d42be2 --- /dev/null +++ b/src/Squidex/app/features/administration/guards/unset-user.guard.ts @@ -0,0 +1,25 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { Injectable } from '@angular/core'; +import { CanActivate } from '@angular/router'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { UsersState } from './../state/users.state'; + +@Injectable() +export class UnsetUserGuard implements CanActivate { + constructor( + private readonly usersState: UsersState + ) { + } + + public canActivate(): Observable { + return this.usersState.select(null).pipe(map(u => u === null)); + } +} \ No newline at end of file diff --git a/src/Squidex/app/features/administration/guards/user-must-exist.guard.spec.ts b/src/Squidex/app/features/administration/guards/user-must-exist.guard.spec.ts new file mode 100644 index 000000000..8fb04cde5 --- /dev/null +++ b/src/Squidex/app/features/administration/guards/user-must-exist.guard.spec.ts @@ -0,0 +1,62 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { Router } from '@angular/router'; +import { of } from 'rxjs'; +import { IMock, Mock, Times } from 'typemoq'; + +import { UserDto } from './../services/users.service'; +import { UsersState } from './../state/users.state'; +import { UserMustExistGuard } from './user-must-exist.guard'; + +describe('UserMustExistGuard', () => { + const route: any = { + params: { + userId: '123' + } + }; + + let usersState: IMock; + let router: IMock; + let userGuard: UserMustExistGuard; + + beforeEach(() => { + router = Mock.ofType(); + usersState = Mock.ofType(); + userGuard = new UserMustExistGuard(usersState.object, router.object); + }); + + it('should load user and return true when found', () => { + usersState.setup(x => x.select('123')) + .returns(() => of({})); + + let result: boolean; + + userGuard.canActivate(route).subscribe(x => { + result = x; + }).unsubscribe(); + + expect(result!).toBeTruthy(); + + usersState.verify(x => x.select('123'), Times.once()); + }); + + it('should load user and return false when not found', () => { + usersState.setup(x => x.select('123')) + .returns(() => of(null)); + + let result: boolean; + + userGuard.canActivate(route).subscribe(x => { + result = x; + }).unsubscribe(); + + expect(result!).toBeFalsy(); + + router.verify(x => x.navigate(['/404']), Times.once()); + }); +}); \ No newline at end of file diff --git a/src/Squidex/app/features/administration/guards/user-must-exist.guard.ts b/src/Squidex/app/features/administration/guards/user-must-exist.guard.ts new file mode 100644 index 000000000..db67514f9 --- /dev/null +++ b/src/Squidex/app/features/administration/guards/user-must-exist.guard.ts @@ -0,0 +1,39 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivate, Router } from '@angular/router'; +import { Observable } from 'rxjs'; +import { map, tap } from 'rxjs/operators'; + +import { allParams } from '@app/framework'; + +import { UsersState } from './../state/users.state'; + +@Injectable() +export class UserMustExistGuard implements CanActivate { + constructor( + private readonly usersState: UsersState, + private readonly router: Router + ) { + } + + public canActivate(route: ActivatedRouteSnapshot): Observable { + const userId = allParams(route)['userId']; + + const result = + this.usersState.select(userId).pipe( + tap(dto => { + if (!dto) { + this.router.navigate(['/404']); + } + }), + map(u => u !== null)); + + return result; + } +} \ No newline at end of file diff --git a/src/Squidex/app/features/administration/module.ts b/src/Squidex/app/features/administration/module.ts index 761d63159..a97719a34 100644 --- a/src/Squidex/app/features/administration/module.ts +++ b/src/Squidex/app/features/administration/module.ts @@ -9,16 +9,22 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { - ResolveUserGuard, SqxFrameworkModule, SqxSharedModule -} from 'shared'; +} from '@app/shared'; import { AdministrationAreaComponent, EventConsumersPageComponent, + EventConsumersService, + EventConsumersState, + RestorePageComponent, + UnsetUserGuard, + UserMustExistGuard, UserPageComponent, - UsersPageComponent + UsersPageComponent, + UsersService, + UsersState } from './declarations'; const routes: Routes = [ @@ -33,20 +39,23 @@ const routes: Routes = [ path: 'event-consumers', component: EventConsumersPageComponent }, + { + path: 'restore', + component: RestorePageComponent + }, { path: 'users', component: UsersPageComponent, children: [ { path: 'new', - component: UserPageComponent + component: UserPageComponent, + canActivate: [UnsetUserGuard] }, { path: ':userId', component: UserPageComponent, - resolve: { - user: ResolveUserGuard - } + canActivate: [UserMustExistGuard] } ] } @@ -58,15 +67,24 @@ const routes: Routes = [ @NgModule({ imports: [ - SqxFrameworkModule, SqxSharedModule, + SqxFrameworkModule, RouterModule.forChild(routes) ], declarations: [ AdministrationAreaComponent, EventConsumersPageComponent, + RestorePageComponent, UserPageComponent, UsersPageComponent + ], + providers: [ + EventConsumersService, + EventConsumersState, + UnsetUserGuard, + UserMustExistGuard, + UsersService, + UsersState ] }) export class SqxFeatureAdministrationModule { } \ No newline at end of file diff --git a/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.html b/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.html index 917428b6e..540d8fde1 100644 --- a/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.html +++ b/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.html @@ -1,89 +1,70 @@ -
-
-
- + + Consumers + - -
+ + -

Event Consumers

-
+ + - - - -
+ + + + + + + + + -
-
-
+ Name + + Position + + Actions +
- - - - - - - + + + - - - - - - - - - -
- Name - - Position - - Actions -
+ + -
- - - - {{eventConsumer.name}} - - - {{eventConsumer.position}} - - - - -
- - + {{eventConsumer.name}} + + + + {{eventConsumer.position}} + + + + + + + + + + +
- \ No newline at end of file + + + + \ No newline at end of file diff --git a/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.ts b/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.ts index 2cd8f789b..30161a18b 100644 --- a/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.ts +++ b/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.ts @@ -6,98 +6,65 @@ */ import { Component, OnDestroy, OnInit } from '@angular/core'; -import { Observable, Subscription } from 'rxjs'; +import { Subscription, timer } from 'rxjs'; +import { onErrorResumeNext, switchMap } from 'rxjs/operators'; -import { - AppContext, - EventConsumerDto, - EventConsumersService, - fadeAnimation, - ImmutableArray, - ModalView -} from 'shared'; +import { DialogModel } from '@app/shared'; + +import { EventConsumerDto } from './../../services/event-consumers.service'; +import { EventConsumersState } from './../../state/event-consumers.state'; @Component({ selector: 'sqx-event-consumers-page', styleUrls: ['./event-consumers-page.component.scss'], - templateUrl: './event-consumers-page.component.html', - providers: [ - AppContext - ], - animations: [ - fadeAnimation - ] + templateUrl: './event-consumers-page.component.html' }) export class EventConsumersPageComponent implements OnDestroy, OnInit { - private subscription: Subscription; + private timerSubscription: Subscription; - public eventConsumerErrorDialog = new ModalView(); + public eventConsumerErrorDialog = new DialogModel(); public eventConsumerError = ''; - public eventConsumers = ImmutableArray.empty(); - constructor(public readonly ctx: AppContext, - private readonly eventConsumersService: EventConsumersService + constructor( + public readonly eventConsumersState: EventConsumersState ) { } public ngOnDestroy() { - this.subscription.unsubscribe(); + this.timerSubscription.unsubscribe(); } public ngOnInit() { - this.load(false, true); + this.eventConsumersState.load(false, true).pipe(onErrorResumeNext()).subscribe(); - this.subscription = - Observable.timer(4000, 4000).subscribe(() => { - this.load(); - }); + this.timerSubscription = + timer(2000, 2000).pipe( + switchMap(x => this.eventConsumersState.load(true, true)), onErrorResumeNext()) + .subscribe(); } - public load(showInfo = false, showError = false) { - this.eventConsumersService.getEventConsumers() - .subscribe(dtos => { - this.eventConsumers = ImmutableArray.of(dtos); + public reload() { + this.eventConsumersState.load(true, false).pipe(onErrorResumeNext()).subscribe(); + } - if (showInfo) { - this.ctx.notifyInfo('Event Consumers reloaded.'); - } - }, error => { - if (showError) { - this.ctx.notifyError(error); - } - }); + public start(es: EventConsumerDto) { + this.eventConsumersState.start(es).pipe(onErrorResumeNext()).subscribe(); } - public start(consumer: EventConsumerDto) { - this.eventConsumersService.startEventConsumer(consumer.name) - .subscribe(() => { - this.eventConsumers = this.eventConsumers.replaceBy('name', consumer.start()); - }, error => { - this.ctx.notifyError(error); - }); + public stop(es: EventConsumerDto) { + this.eventConsumersState.stop(es).pipe(onErrorResumeNext()).subscribe(); } - public stop(consumer: EventConsumerDto) { - this.eventConsumersService.stopEventConsumer(consumer.name) - .subscribe(() => { - this.eventConsumers = this.eventConsumers.replaceBy('name', consumer.stop()); - }, error => { - this.ctx.notifyError(error); - }); + public reset(es: EventConsumerDto) { + this.eventConsumersState.reset(es).pipe(onErrorResumeNext()).subscribe(); } - public reset(consumer: EventConsumerDto) { - this.eventConsumersService.resetEventConsumer(consumer.name) - .subscribe(() => { - this.eventConsumers = this.eventConsumers.replaceBy('name', consumer.reset()); - }, error => { - this.ctx.notifyError(error); - }); + public trackByEventConsumer(index: number, es: EventConsumerDto) { + return es.name; } public showError(eventConsumer: EventConsumerDto) { this.eventConsumerError = eventConsumer.error; this.eventConsumerErrorDialog.show(); } -} - +} \ No newline at end of file diff --git a/src/Squidex/app/features/administration/pages/messages.ts b/src/Squidex/app/features/administration/pages/messages.ts deleted file mode 100644 index 5cec4e455..000000000 --- a/src/Squidex/app/features/administration/pages/messages.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Squidex Headless CMS - * - * @license - * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. - */ - -import { UserDto } from 'shared'; - -export class UserCreated { - constructor( - public readonly user: UserDto - ) { - } -} - -export class UserUpdated { - constructor( - public readonly user: UserDto - ) { - } -} \ No newline at end of file diff --git a/src/Squidex/app/features/administration/pages/restore/restore-page.component.html b/src/Squidex/app/features/administration/pages/restore/restore-page.component.html new file mode 100644 index 000000000..55a237ff0 --- /dev/null +++ b/src/Squidex/app/features/administration/pages/restore/restore-page.component.html @@ -0,0 +1,68 @@ + + + + + Restore Backup (BETA) + + + + +
+
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+

Last Restore Operation

+
+ +
+ {{job.url}} +
+
+
+
+
+ {{row}} +
+
+ +
+
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
\ No newline at end of file diff --git a/src/Squidex/app/features/administration/pages/restore/restore-page.component.scss b/src/Squidex/app/features/administration/pages/restore/restore-page.component.scss new file mode 100644 index 000000000..ccd571a75 --- /dev/null +++ b/src/Squidex/app/features/administration/pages/restore/restore-page.component.scss @@ -0,0 +1,68 @@ +@import '_vars'; +@import '_mixins'; + +$circle-size: 2rem; + +h3 { + margin: 0; +} + +.section { + margin-bottom: .8rem; +} + +.container { + padding-top: 2rem; +} + +.card { + &-header { + h3 { + line-height: $circle-size; + } + } + + &-footer { + font-size: .9rem; + } + + &-body { + font-family: monospace; + background: $color-border; + max-height: 400px; + min-height: 300px; + overflow-y: scroll; + } +} + +.restore { + &-status { + & { + @include circle($circle-size); + line-height: $circle-size + .1rem; + text-align: center; + font-size: .6 * $circle-size; + font-weight: normal; + background: $color-border; + color: $color-dark-foreground; + vertical-align: middle; + } + + &-pending { + color: inherit; + } + + &-failed { + background: $color-theme-error; + } + + &-success { + background: $color-theme-green; + } + } + + &-url { + @include truncate; + line-height: 30px; + } +} \ No newline at end of file diff --git a/src/Squidex/app/features/administration/pages/restore/restore-page.component.ts b/src/Squidex/app/features/administration/pages/restore/restore-page.component.ts new file mode 100644 index 000000000..fa8977ccf --- /dev/null +++ b/src/Squidex/app/features/administration/pages/restore/restore-page.component.ts @@ -0,0 +1,68 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormBuilder } from '@angular/forms'; +import { Subscription, timer } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { + AuthService, + BackupsService, + DialogService, + RestoreDto, + RestoreForm +} from '@app/shared'; + +@Component({ + selector: 'sqx-restore-page', + styleUrls: ['./restore-page.component.scss'], + templateUrl: './restore-page.component.html' +}) +export class RestorePageComponent implements OnDestroy, OnInit { + private timerSubscription: Subscription; + + public restoreJob: RestoreDto | null; + public restoreForm = new RestoreForm(this.formBuilder); + + constructor( + public readonly authState: AuthService, + private readonly backupsService: BackupsService, + private readonly dialogs: DialogService, + private readonly formBuilder: FormBuilder + ) { + } + + public ngOnDestroy() { + this.timerSubscription.unsubscribe(); + } + + public ngOnInit() { + this.timerSubscription = + timer(0, 2000).pipe(switchMap(() => this.backupsService.getRestore())) + .subscribe(dto => { + if (dto !== null) { + this.restoreJob = dto; + } + }); + } + + public restore() { + const value = this.restoreForm.submit(); + + if (value) { + this.restoreForm.submitCompleted({}); + + this.backupsService.postRestore(value) + .subscribe(() => { + this.dialogs.notifyInfo('Restore started, it can take several minutes to complete.'); + }, error => { + this.dialogs.notifyError(error); + }); + } + } +} \ No newline at end of file diff --git a/src/Squidex/app/features/administration/pages/users/user-page.component.html b/src/Squidex/app/features/administration/pages/users/user-page.component.html index 8a019c60e..ac1d2e561 100644 --- a/src/Squidex/app/features/administration/pages/users/user-page.component.html +++ b/src/Squidex/app/features/administration/pages/users/user-page.component.html @@ -1,73 +1,62 @@ -
+ - -
-
-
- -
- - - -

- New User -

-

- Edit User -

-
- + + + + Edit User + + + + New User + + + + + + + - - - -
+ + -
-
-
-
-
+
+ -
- + - - - -
-
- + +
+
+ - + - -
+ +
-
-
- +
+
+ - + - -
+ +
-
- +
+ - + - -
+
-
+ - \ No newline at end of file + \ No newline at end of file diff --git a/src/Squidex/app/features/administration/pages/users/user-page.component.ts b/src/Squidex/app/features/administration/pages/users/user-page.component.ts index deddd7689..bfa55f8fc 100644 --- a/src/Squidex/app/features/administration/pages/users/user-page.component.ts +++ b/src/Squidex/app/features/administration/pages/users/user-page.component.ts @@ -5,148 +5,73 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { Component, OnInit } from '@angular/core'; -import { FormGroup, FormBuilder, Validators } from '@angular/forms'; -import { Router } from '@angular/router'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormBuilder } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Subscription } from 'rxjs'; -import { - AppContext, - UserDto, - UserManagementService, - ValidatorsEx -} from 'shared'; - -import { UserCreated, UserUpdated } from './../messages'; +import { UserDto } from './../../services/users.service'; +import { UserForm, UsersState } from './../../state/users.state'; @Component({ selector: 'sqx-user-page', styleUrls: ['./user-page.component.scss'], - templateUrl: './user-page.component.html', - providers: [ - AppContext - ] + templateUrl: './user-page.component.html' }) -export class UserPageComponent implements OnInit { - public user: UserDto; +export class UserPageComponent implements OnDestroy, OnInit { + private selectedUserSubscription: Subscription; - public userFormSubmitted = false; - public userForm: FormGroup; - public userFormError = ''; + public user?: { user: UserDto, isCurrentUser: boolean }; - public isCurrentUser = false; - public isNewMode = false; + public userForm = new UserForm(this.formBuilder); - constructor(public readonly ctx: AppContext, + constructor( + public readonly usersState: UsersState, private readonly formBuilder: FormBuilder, - private readonly router: Router, - private readonly userManagementService: UserManagementService + private readonly route: ActivatedRoute, + private readonly router: Router ) { } - public ngOnInit() { - this.ctx.route.data.map(d => d.user) - .subscribe((user: UserDto) => { - this.user = user; + public ngOnDestroy() { + this.selectedUserSubscription.unsubscribe(); + } - this.setupAndPopulateForm(); - }); + public ngOnInit() { + this.selectedUserSubscription = + this.usersState.selectedUser + .subscribe(selectedUser => { + this.user = selectedUser; + + if (selectedUser) { + this.userForm.load(selectedUser.user); + } + }); } public save() { - this.userFormSubmitted = true; - - if (this.userForm.valid) { - this.userForm.disable(); - - const requestDto = this.userForm.value; + const value = this.userForm.submit(); - if (this.isNewMode) { - this.userManagementService.postUser(requestDto) - .subscribe(created => { - this.user = - new UserDto( - created.id, - requestDto.email, - requestDto.displayName, - created.pictureUrl!, - false); - - this.ctx.notifyInfo('User created successfully.'); - - this.emitUserCreated(this.user); - this.back(); + if (value) { + if (this.user) { + this.usersState.update(this.user.user, value) + .subscribe(user => { + this.userForm.submitCompleted(); }, error => { - this.resetUserForm(error.displayMessage); + this.userForm.submitFailed(error); }); } else { - this.userManagementService.putUser(this.user.id, requestDto) - .subscribe(() => { - this.user = - this.user.update( - requestDto.email, - requestDto.displayMessage); - - this.ctx.notifyInfo('User saved successfully.'); - - this.emitUserUpdated(this.user); - this.resetUserForm(); + this.usersState.create(value) + .subscribe(user => { + this.back(); }, error => { - this.resetUserForm(error.displayMessage); + this.userForm.submitFailed(error); }); } } } private back() { - this.router.navigate(['../'], { relativeTo: this.ctx.route, replaceUrl: true }); - } - - private emitUserCreated(user: UserDto) { - this.ctx.bus.emit(new UserCreated(user)); - } - - private emitUserUpdated(user: UserDto) { - this.ctx.bus.emit(new UserUpdated(user)); - } - - private setupAndPopulateForm() { - const input = this.user || {}; - - this.isNewMode = !this.user; - this.userForm = - this.formBuilder.group({ - email: [input['email'], - [ - Validators.email, - Validators.required, - Validators.maxLength(100) - ]], - displayName: [input['displayName'], - [ - Validators.required, - Validators.maxLength(100) - ]], - password: ['', - [ - this.isNewMode ? Validators.required : Validators.nullValidator - ]], - passwordConfirm: ['', - [ - ValidatorsEx.match('password', 'Passwords must be the same.') - ]] - }); - - this.isCurrentUser = this.user && this.user.id === this.ctx.userId; - - this.resetUserForm(); - } - - private resetUserForm(message: string = '') { - this.userForm.enable(); - this.userForm.controls['password'].reset(); - this.userForm.controls['passwordConfirm'].reset(); - this.userFormSubmitted = false; - this.userFormError = message; + this.router.navigate(['../'], { relativeTo: this.route, replaceUrl: true }); } } - diff --git a/src/Squidex/app/features/administration/pages/users/users-page.component.html b/src/Squidex/app/features/administration/pages/users/users-page.component.html index de7e0fdd9..8033976c6 100644 --- a/src/Squidex/app/features/administration/pages/users/users-page.component.html +++ b/src/Squidex/app/features/administration/pages/users/users-page.component.html @@ -1,107 +1,89 @@ - -
-
-
- + + + Users + - - - + + -
- -
+ + + - -
- -

Users

-
+
+ +
- - - -
+ + -
-
-
+ +
+ + + + + + + + + +
+   + + Name + + Email + + Actions +
+
+ +
+
- - - - - - + + + + + + - + +
-   - - Name - - Email - - Actions -
+ + + {{userInfo.user.displayName}} + + {{userInfo.user.email}} + + + + + + + +
- -
-
- - - - - - - - - - - - -
- - - {{user.displayName}} - - {{user.email}} - - - - - - -
-
-
- - -
+ -
+ \ No newline at end of file diff --git a/src/Squidex/app/features/administration/pages/users/users-page.component.ts b/src/Squidex/app/features/administration/pages/users/users-page.component.ts index f8c38cc94..8d8c5c052 100644 --- a/src/Squidex/app/features/administration/pages/users/users-page.component.ts +++ b/src/Squidex/app/features/administration/pages/users/users-page.component.ts @@ -5,113 +5,56 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { FormControl } from '@angular/forms'; -import { Subscription } from 'rxjs'; +import { onErrorResumeNext } from 'rxjs/operators'; -import { - AppContext, - ImmutableArray, - Pager, - UserDto, - UserManagementService -} from 'shared'; - -import { UserCreated, UserUpdated } from './../messages'; +import { UserDto } from './../../services/users.service'; +import { UsersState } from './../../state/users.state'; @Component({ selector: 'sqx-users-page', styleUrls: ['./users-page.component.scss'], - templateUrl: './users-page.component.html', - providers: [ - AppContext - ] + templateUrl: './users-page.component.html' }) -export class UsersPageComponent implements OnDestroy, OnInit { - private userCreatedSubscription: Subscription; - private userUpdatedSubscription: Subscription; - - public usersItems = ImmutableArray.empty(); - public usersPager = new Pager(0); +export class UsersPageComponent implements OnInit { public usersFilter = new FormControl(); - public usersQuery = ''; - constructor(public readonly ctx: AppContext, - private readonly userManagementService: UserManagementService + constructor( + public readonly usersState: UsersState ) { } - public ngOnDestroy() { - this.userCreatedSubscription.unsubscribe(); - this.userUpdatedSubscription.unsubscribe(); - } - public ngOnInit() { - this.userCreatedSubscription = - this.ctx.bus.of(UserCreated) - .subscribe(message => { - this.usersItems = this.usersItems.pushFront(message.user); - this.usersPager = this.usersPager.incrementCount(); - }); - - this.userUpdatedSubscription = - this.ctx.bus.of(UserUpdated) - .subscribe(message => { - this.usersItems = this.usersItems.replaceBy('id', message.user); - }); + this.usersState.load().pipe(onErrorResumeNext()).subscribe(); + } - this.load(); + public reload() { + this.usersState.load(true).pipe(onErrorResumeNext()).subscribe(); } public search() { - this.usersPager = new Pager(0); - this.usersQuery = this.usersFilter.value; - - this.load(); + this.usersState.search(this.usersFilter.value).pipe(onErrorResumeNext()).subscribe(); } - public load(showInfo = false) { - this.userManagementService.getUsers(this.usersPager.pageSize, this.usersPager.skip, this.usersQuery) - .subscribe(dtos => { - this.usersItems = ImmutableArray.of(dtos.items); - this.usersPager = this.usersPager.setCount(dtos.total); + public goPrev() { + this.usersState.goPrev().pipe(onErrorResumeNext()).subscribe(); + } - if (showInfo) { - this.ctx.notifyInfo('Users reloaded.'); - } - }, error => { - this.ctx.notifyError(error); - }); + public goNext() { + this.usersState.goNext().pipe(onErrorResumeNext()).subscribe(); } public lock(user: UserDto) { - this.userManagementService.lockUser(user.id) - .subscribe(() => { - this.usersItems = this.usersItems.replaceBy('id', user.lock()); - }, error => { - this.ctx.notifyError(error); - }); + this.usersState.lock(user).pipe(onErrorResumeNext()).subscribe(); } public unlock(user: UserDto) { - this.userManagementService.unlockUser(user.id) - .subscribe(() => { - this.usersItems = this.usersItems.replaceBy('id', user.unlock()); - }, error => { - this.ctx.notifyError(error); - }); - } - - public goNext() { - this.usersPager = this.usersPager.goNext(); - - this.load(); + this.usersState.unlock(user).pipe(onErrorResumeNext()).subscribe(); } - public goPrev() { - this.usersPager = this.usersPager.goPrev(); - - this.load(); + public trackByUser(index: number, userInfo: { user: UserDto }) { + return userInfo.user.id; } } diff --git a/src/Squidex/app/features/administration/services/event-consumers.service.spec.ts b/src/Squidex/app/features/administration/services/event-consumers.service.spec.ts new file mode 100644 index 000000000..5eacb6d62 --- /dev/null +++ b/src/Squidex/app/features/administration/services/event-consumers.service.spec.ts @@ -0,0 +1,108 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { inject, TestBed } from '@angular/core/testing'; + +import { ApiUrlConfig } from '@app/framework'; + +import { EventConsumerDto, EventConsumersService } from './event-consumers.service'; + +describe('EventConsumersService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + HttpClientTestingModule + ], + providers: [ + EventConsumersService, + { provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') } + ] + }); + }); + + afterEach(inject([HttpTestingController], (httpMock: HttpTestingController) => { + httpMock.verify(); + })); + + it('should make get request to get event consumers', + inject([EventConsumersService, HttpTestingController], (eventConsumersService: EventConsumersService, httpMock: HttpTestingController) => { + + let eventConsumers: EventConsumerDto[]; + + eventConsumersService.getEventConsumers().subscribe(result => { + eventConsumers = result; + }); + + const req = httpMock.expectOne('http://service/p/api/event-consumers'); + + expect(req.request.method).toEqual('GET'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush([ + { + name: 'event-consumer1', + position: '13', + isStopped: true, + isResetting: true, + error: 'an error 1' + }, + { + name: 'event-consumer2', + position: '29', + isStopped: true, + isResetting: true, + error: 'an error 2' + } + ]); + + expect(eventConsumers!).toEqual( + [ + new EventConsumerDto('event-consumer1', true, true, 'an error 1', '13'), + new EventConsumerDto('event-consumer2', true, true, 'an error 2', '29') + ]); + })); + + it('should make put request to start event consumer', + inject([EventConsumersService, HttpTestingController], (eventConsumersService: EventConsumersService, httpMock: HttpTestingController) => { + + eventConsumersService.putStart('event-consumer1').subscribe(); + + const req = httpMock.expectOne('http://service/p/api/event-consumers/event-consumer1/start'); + + expect(req.request.method).toEqual('PUT'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({}); + })); + + it('should make put request to stop event consumer', + inject([EventConsumersService, HttpTestingController], (eventConsumersService: EventConsumersService, httpMock: HttpTestingController) => { + + eventConsumersService.putStop('event-consumer1').subscribe(); + + const req = httpMock.expectOne('http://service/p/api/event-consumers/event-consumer1/stop'); + + expect(req.request.method).toEqual('PUT'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({}); + })); + + it('should make put request to reset event consumer', + inject([EventConsumersService, HttpTestingController], (eventConsumersService: EventConsumersService, httpMock: HttpTestingController) => { + + eventConsumersService.putReset('event-consumer1').subscribe(); + + const req = httpMock.expectOne('http://service/p/api/event-consumers/event-consumer1/reset'); + + expect(req.request.method).toEqual('PUT'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({}); + })); +}); \ No newline at end of file diff --git a/src/Squidex/app/features/administration/services/event-consumers.service.ts b/src/Squidex/app/features/administration/services/event-consumers.service.ts new file mode 100644 index 000000000..c768c9231 --- /dev/null +++ b/src/Squidex/app/features/administration/services/event-consumers.service.ts @@ -0,0 +1,85 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { + ApiUrlConfig, + HTTP, + Model, + pretifyError +} from '@app/shared'; + +export class EventConsumerDto extends Model { + constructor( + public readonly name: string, + public readonly isStopped: boolean, + public readonly isResetting: boolean, + public readonly error: string, + public readonly position: string + ) { + super(); + } + + public with(value: Partial): EventConsumerDto { + return this.clone(value); + } +} + +@Injectable() +export class EventConsumersService { + constructor( + private readonly http: HttpClient, + private readonly apiUrl: ApiUrlConfig + ) { + } + + public getEventConsumers(): Observable { + const url = this.apiUrl.buildUrl('/api/event-consumers'); + + return HTTP.getVersioned(this.http, url).pipe( + map(response => { + const body = response.payload.body; + + const items: any[] = body; + + return items.map(item => { + return new EventConsumerDto( + item.name, + item.isStopped, + item.isResetting, + item.error, + item.position); + }); + }), + pretifyError('Failed to load event consumers. Please reload.')); + } + + public putStart(name: string): Observable { + const url = this.apiUrl.buildUrl(`api/event-consumers/${name}/start`); + + return HTTP.putVersioned(this.http, url, {}).pipe( + pretifyError('Failed to start event consumer. Please reload.')); + } + + public putStop(name: string): Observable { + const url = this.apiUrl.buildUrl(`api/event-consumers/${name}/stop`); + + return HTTP.putVersioned(this.http, url, {}).pipe( + pretifyError('Failed to stop event consumer. Please reload.')); + } + + public putReset(name: string): Observable { + const url = this.apiUrl.buildUrl(`api/event-consumers/${name}/reset`); + + return HTTP.putVersioned(this.http, url, {}).pipe( + pretifyError('Failed to reset event consumer. Please reload.')); + } +} \ No newline at end of file diff --git a/src/Squidex/app/features/administration/services/users.service.spec.ts b/src/Squidex/app/features/administration/services/users.service.spec.ts new file mode 100644 index 000000000..4e769e6e7 --- /dev/null +++ b/src/Squidex/app/features/administration/services/users.service.spec.ts @@ -0,0 +1,202 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { inject, TestBed } from '@angular/core/testing'; + +import { ApiUrlConfig } from '@app/framework'; + +import { + CreateUserDto, + UpdateUserDto, + UserDto, + UsersDto, + UsersService +} from './users.service'; + +describe('UsersService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + HttpClientTestingModule + ], + providers: [ + UsersService, + { provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') } + ] + }); + }); + + afterEach(inject([HttpTestingController], (httpMock: HttpTestingController) => { + httpMock.verify(); + })); + + it('should make get request to get many users', + inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { + + let users: UsersDto; + + userManagementService.getUsers(20, 30).subscribe(result => { + users = result; + }); + + const req = httpMock.expectOne('http://service/p/api/user-management?take=20&skip=30&query='); + + expect(req.request.method).toEqual('GET'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({ + total: 100, + items: [ + { + id: '123', + email: 'mail1@domain.com', + displayName: 'User1', + isLocked: true + }, + { + id: '456', + email: 'mail2@domain.com', + displayName: 'User2', + isLocked: true + } + ] + }); + + expect(users!).toEqual( + new UsersDto(100, [ + new UserDto('123', 'mail1@domain.com', 'User1', true), + new UserDto('456', 'mail2@domain.com', 'User2', true) + ])); + })); + + it('should make get request with query to get many users', + inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { + + let users: UsersDto; + + userManagementService.getUsers(20, 30, 'my-query').subscribe(result => { + users = result; + }); + + const req = httpMock.expectOne('http://service/p/api/user-management?take=20&skip=30&query=my-query'); + + expect(req.request.method).toEqual('GET'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({ + total: 100, + items: [ + { + id: '123', + email: 'mail1@domain.com', + displayName: 'User1', + isLocked: true + }, + { + id: '456', + email: 'mail2@domain.com', + displayName: 'User2', + isLocked: true + } + ] + }); + + expect(users!).toEqual( + new UsersDto(100, [ + new UserDto('123', 'mail1@domain.com', 'User1', true), + new UserDto('456', 'mail2@domain.com', 'User2', true) + ])); + })); + + it('should make get request to get single user', + inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { + + let user: UserDto; + + userManagementService.getUser('123').subscribe(result => { + user = result; + }); + + const req = httpMock.expectOne('http://service/p/api/user-management/123'); + + expect(req.request.method).toEqual('GET'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({ + id: '123', + email: 'mail1@domain.com', + displayName: 'User1', + pictureUrl: 'path/to/image1', + isLocked: true + }); + + expect(user!).toEqual(new UserDto('123', 'mail1@domain.com', 'User1', true)); + })); + + it('should make post request to create user', + inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { + + const dto = new CreateUserDto('mail@squidex.io', 'Squidex User', 'password'); + + let user: UserDto; + + userManagementService.postUser(dto).subscribe(result => { + user = result; + }); + + const req = httpMock.expectOne('http://service/p/api/user-management'); + + expect(req.request.method).toEqual('POST'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({ id: '123', pictureUrl: 'path/to/image1' }); + + expect(user!).toEqual(new UserDto('123', dto.email, dto.displayName, false)); + })); + + it('should make put request to update user', + inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { + + const dto = new UpdateUserDto('mail@squidex.io', 'Squidex User', 'password'); + + userManagementService.putUser('123', dto).subscribe(); + + const req = httpMock.expectOne('http://service/p/api/user-management/123'); + + expect(req.request.method).toEqual('PUT'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({}); + })); + + it('should make put request to lock user', + inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { + + userManagementService.lockUser('123').subscribe(); + + const req = httpMock.expectOne('http://service/p/api/user-management/123/lock'); + + expect(req.request.method).toEqual('PUT'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({}); + })); + + it('should make put request to unlock user', + inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { + + userManagementService.unlockUser('123').subscribe(); + + const req = httpMock.expectOne('http://service/p/api/user-management/123/unlock'); + + expect(req.request.method).toEqual('PUT'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({}); + })); +}); \ No newline at end of file diff --git a/src/Squidex/app/features/administration/services/users.service.ts b/src/Squidex/app/features/administration/services/users.service.ts new file mode 100644 index 000000000..8ffa5cabb --- /dev/null +++ b/src/Squidex/app/features/administration/services/users.service.ts @@ -0,0 +1,144 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { + ApiUrlConfig, + HTTP, + Model, + pretifyError +} from '@app/shared'; + +export class UsersDto extends Model { + constructor( + public readonly total: number, + public readonly items: UserDto[] + ) { + super(); + } +} + +export class UserDto extends Model { + constructor( + public readonly id: string, + public readonly email: string, + public readonly displayName: string, + public readonly isLocked: boolean + ) { + super(); + } + + public with(value: Partial): UserDto { + return this.clone(value); + } +} + +export class CreateUserDto { + constructor( + public readonly email: string, + public readonly displayName: string, + public readonly password: string + ) { + } +} + +export class UpdateUserDto { + constructor( + public readonly email: string, + public readonly displayName: string, + public readonly password?: string + ) { + } +} + +@Injectable() +export class UsersService { + constructor( + private readonly http: HttpClient, + private readonly apiUrl: ApiUrlConfig + ) { + } + + public getUsers(take: number, skip: number, query?: string): Observable { + const url = this.apiUrl.buildUrl(`api/user-management?take=${take}&skip=${skip}&query=${query || ''}`); + + return HTTP.getVersioned(this.http, url).pipe( + map(response => { + const body = response.payload.body; + + const items: any[] = body.items; + + const users = items.map(item => { + return new UserDto( + item.id, + item.email, + item.displayName, + item.isLocked); + }); + + return new UsersDto(body.total, users); + }), + pretifyError('Failed to load users. Please reload.')); + } + + public getUser(id: string): Observable { + const url = this.apiUrl.buildUrl(`api/user-management/${id}`); + + return HTTP.getVersioned(this.http, url).pipe( + map(response => { + const body = response.payload.body; + + return new UserDto( + body.id, + body.email, + body.displayName, + body.isLocked); + }), + pretifyError('Failed to load user. Please reload.')); + } + + public postUser(dto: CreateUserDto): Observable { + const url = this.apiUrl.buildUrl('api/user-management'); + + return HTTP.postVersioned(this.http, url, dto).pipe( + map(response => { + const body = response.payload.body; + + return new UserDto( + body.id, + dto.email, + dto.displayName, + false); + }), + pretifyError('Failed to create user. Please reload.')); + } + + public putUser(id: string, dto: UpdateUserDto): Observable { + const url = this.apiUrl.buildUrl(`api/user-management/${id}`); + + return HTTP.putVersioned(this.http, url, dto).pipe( + pretifyError('Failed to update user. Please reload.')); + } + + public lockUser(id: string): Observable { + const url = this.apiUrl.buildUrl(`api/user-management/${id}/lock`); + + return HTTP.putVersioned(this.http, url, {}).pipe( + pretifyError('Failed to load users. Please retry.')); + } + + public unlockUser(id: string): Observable { + const url = this.apiUrl.buildUrl(`api/user-management/${id}/unlock`); + + return HTTP.putVersioned(this.http, url, {}).pipe( + pretifyError('Failed to load users. Please retry.')); + } +} \ No newline at end of file diff --git a/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts b/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts new file mode 100644 index 000000000..45aa75468 --- /dev/null +++ b/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts @@ -0,0 +1,110 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { of, throwError } from 'rxjs'; +import { onErrorResumeNext } from 'rxjs/operators'; +import { IMock, It, Mock, Times } from 'typemoq'; + +import { DialogService } from '@app/shared'; + +import { EventConsumerDto, EventConsumersService } from './../services/event-consumers.service'; +import { EventConsumersState } from './event-consumers.state'; + +describe('EventConsumersState', () => { + const oldConsumers = [ + new EventConsumerDto('name1', false, false, 'error', '1'), + new EventConsumerDto('name2', true, true, 'error', '2') + ]; + + let dialogs: IMock; + let eventConsumersService: IMock; + let eventConsumersState: EventConsumersState; + + beforeEach(() => { + dialogs = Mock.ofType(); + + eventConsumersService = Mock.ofType(); + + eventConsumersService.setup(x => x.getEventConsumers()) + .returns(() => of(oldConsumers)); + + eventConsumersState = new EventConsumersState(dialogs.object, eventConsumersService.object); + eventConsumersState.load().subscribe(); + }); + + it('should load event consumers', () => { + expect(eventConsumersState.snapshot.eventConsumers.values).toEqual(oldConsumers); + expect(eventConsumersState.snapshot.isLoaded).toBeTruthy(); + + expect().nothing(); + + dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); + }); + + it('should show notification on load when reload is true', () => { + eventConsumersState.load(true).subscribe(); + + expect().nothing(); + + dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.once()); + }); + + it('should show notification on load error when silent is true', () => { + eventConsumersService.setup(x => x.getEventConsumers()) + .returns(() => throwError({})); + + eventConsumersState.load(true, true).pipe(onErrorResumeNext()).subscribe(); + + expect().nothing(); + + dialogs.verify(x => x.notifyError(It.isAny()), Times.once()); + }); + + it('should not show notification on load error when flag is false', () => { + eventConsumersService.setup(x => x.getEventConsumers()) + .returns(() => throwError({})); + + eventConsumersState.load().pipe(onErrorResumeNext()).subscribe(); + + expect().nothing(); + + dialogs.verify(x => x.notifyError(It.isAny()), Times.never()); + }); + + it('should unmark as stopped when started', () => { + eventConsumersService.setup(x => x.putStart(oldConsumers[1].name)) + .returns(() => of({})); + + eventConsumersState.start(oldConsumers[1]).subscribe(); + + const es_1 = eventConsumersState.snapshot.eventConsumers.at(1); + + expect(es_1.isStopped).toBeFalsy(); + }); + + it('should mark as stopped when stopped', () => { + eventConsumersService.setup(x => x.putStop(oldConsumers[0].name)) + .returns(() => of({})); + + eventConsumersState.stop(oldConsumers[0]).subscribe(); + + const es_1 = eventConsumersState.snapshot.eventConsumers.at(0); + + expect(es_1.isStopped).toBeTruthy(); + }); + + it('should mark as resetting when reset', () => { + eventConsumersService.setup(x => x.putReset(oldConsumers[0].name)) + .returns(() => of({})); + + eventConsumersState.reset(oldConsumers[0]).subscribe(); + + const es_1 = eventConsumersState.snapshot.eventConsumers.at(0); + + expect(es_1.isResetting).toBeTruthy(); + }); +}); \ No newline at end of file diff --git a/src/Squidex/app/features/administration/state/event-consumers.state.ts b/src/Squidex/app/features/administration/state/event-consumers.state.ts new file mode 100644 index 000000000..ef14e7963 --- /dev/null +++ b/src/Squidex/app/features/administration/state/event-consumers.state.ts @@ -0,0 +1,107 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { Injectable } from '@angular/core'; +import { Observable, throwError } from 'rxjs'; +import { catchError, distinctUntilChanged, map, tap } from 'rxjs/operators'; + +import { + DialogService, + ImmutableArray, + notify, + State +} from '@app/shared'; + +import { EventConsumerDto, EventConsumersService } from './../services/event-consumers.service'; + +interface Snapshot { + eventConsumers: ImmutableArray; + + isLoaded?: false; +} + +@Injectable() +export class EventConsumersState extends State { + public eventConsumers = + this.changes.pipe(map(x => x.eventConsumers), + distinctUntilChanged()); + + public isLoaded = + this.changes.pipe(map(x => !!x.isLoaded), + distinctUntilChanged()); + + constructor( + private readonly dialogs: DialogService, + private readonly eventConsumersService: EventConsumersService + ) { + super({ eventConsumers: ImmutableArray.empty() }); + } + + public load(isReload = false, silent = false): Observable { + if (!isReload) { + this.resetState(); + } + + return this.eventConsumersService.getEventConsumers().pipe( + tap(dtos => { + if (isReload && !silent) { + this.dialogs.notifyInfo('Event Consumers reloaded.'); + } + + this.next(s => { + const eventConsumers = ImmutableArray.of(dtos); + + return { ...s, eventConsumers, isLoaded: true }; + }); + }), + catchError(error => { + if (silent) { + this.dialogs.notifyError(error); + } + + return throwError(error); + })); + } + + public start(eventConsumer: EventConsumerDto): Observable { + return this.eventConsumersService.putStart(eventConsumer.name).pipe( + tap(() => { + this.replaceEventConsumer(setStopped(eventConsumer, false)); + }), + notify(this.dialogs)); + } + + public stop(eventConsumer: EventConsumerDto): Observable { + return this.eventConsumersService.putStop(eventConsumer.name).pipe( + tap(() => { + this.replaceEventConsumer(setStopped(eventConsumer, true)); + }), + notify(this.dialogs)); + } + + public reset(eventConsumer: EventConsumerDto): Observable { + return this.eventConsumersService.putReset(eventConsumer.name).pipe( + tap(() => { + this.replaceEventConsumer(reset(eventConsumer)); + }), + notify(this.dialogs)); + } + + private replaceEventConsumer(eventConsumer: EventConsumerDto) { + this.next(s => { + const eventConsumers = s.eventConsumers.replaceBy('name', eventConsumer); + + return { ...s, eventConsumers }; + }); + } +} + +const setStopped = (eventConsumer: EventConsumerDto, isStopped: boolean) => + eventConsumer.with({ isStopped }); + +const reset = (eventConsumer: EventConsumerDto) => + eventConsumer.with({ isResetting: true }); \ No newline at end of file diff --git a/src/Squidex/app/features/administration/state/users.state.spec.ts b/src/Squidex/app/features/administration/state/users.state.spec.ts new file mode 100644 index 000000000..a81b1ab0c --- /dev/null +++ b/src/Squidex/app/features/administration/state/users.state.spec.ts @@ -0,0 +1,225 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { of, throwError } from 'rxjs'; +import { IMock, It, Mock, Times } from 'typemoq'; + +import { AuthService, DialogService } from '@app/shared'; + +import { UsersState } from './users.state'; + +import { + CreateUserDto, + UpdateUserDto, + UserDto, + UsersDto, + UsersService +} from './../services/users.service'; + +describe('UsersState', () => { + const oldUsers = [ + new UserDto('id1', 'mail1@mail.de', 'name1', false), + new UserDto('id2', 'mail2@mail.de', 'name2', true) + ]; + + const newUser = new UserDto('id3', 'mail3@mail.de', 'name3', false); + + let authService: IMock; + let dialogs: IMock; + let usersService: IMock; + let usersState: UsersState; + + beforeEach(() => { + authService = Mock.ofType(); + + authService.setup(x => x.user) + .returns(() => { id: 'id2' }); + + dialogs = Mock.ofType(); + + usersService = Mock.ofType(); + + usersService.setup(x => x.getUsers(10, 0, undefined)) + .returns(() => of(new UsersDto(200, oldUsers))); + + usersState = new UsersState(authService.object, dialogs.object, usersService.object); + usersState.load().subscribe(); + }); + + it('should load users', () => { + expect(usersState.snapshot.users.values).toEqual([ + { isCurrentUser: false, user: oldUsers[0] }, + { isCurrentUser: true, user: oldUsers[1] } + ]); + expect(usersState.snapshot.usersPager.numberOfItems).toEqual(200); + expect(usersState.snapshot.isLoaded).toBeTruthy(); + + dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); + }); + + it('should show notification on load when reload is true', () => { + usersState.load(true).subscribe(); + + expect().nothing(); + + dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.once()); + }); + + it('should replace selected user when reloading', () => { + usersState.select('id1').subscribe(); + + const newUsers = [ + new UserDto('id1', 'mail1@mail.de_new', 'name1_new', false), + new UserDto('id2', 'mail2@mail.de_new', 'name2_new', true) + ]; + + usersService.setup(x => x.getUsers(10, 0, undefined)) + .returns(() => of(new UsersDto(200, newUsers))); + + usersState.load().subscribe(); + + expect(usersState.snapshot.selectedUser).toEqual({ isCurrentUser: false, user: newUsers[0] }); + }); + + it('should return user on select and not load when already loaded', () => { + let selectedUser: UserDto; + + usersState.select('id1').subscribe(x => { + selectedUser = x!; + }); + + expect(selectedUser!).toEqual(oldUsers[0]); + expect(usersState.snapshot.selectedUser).toEqual({ isCurrentUser: false, user: oldUsers[0] }); + + usersService.verify(x => x.getUser(It.isAnyString()), Times.never()); + }); + + it('should return user on select and load when not loaded', () => { + usersService.setup(x => x.getUser('id3')) + .returns(() => of(newUser)); + + let selectedUser: UserDto; + + usersState.select('id3').subscribe(x => { + selectedUser = x!; + }); + + expect(selectedUser!).toEqual(newUser); + expect(usersState.snapshot.selectedUser).toEqual({ isCurrentUser: false, user: newUser }); + + usersService.verify(x => x.getUser('id3'), Times.once()); + }); + + it('should return null on select when unselecting user', () => { + let selectedUser: UserDto; + + usersState.select(null).subscribe(x => { + selectedUser = x!; + }); + + expect(selectedUser!).toBeNull(); + expect(usersState.snapshot.selectedUser).toBeNull(); + + usersService.verify(x => x.getUser(It.isAnyString()), Times.never()); + }); + + it('should return null on select when user is not found', () => { + usersService.setup(x => x.getUser('unknown')) + .returns(() => throwError({})); + + let selectedUser: UserDto; + + usersState.select('unknown').subscribe(x => { + selectedUser = x!; + }).unsubscribe(); + + expect(selectedUser!).toBeNull(); + expect(usersState.snapshot.selectedUser).toBeNull(); + }); + + it('should mark as locked when locked', () => { + usersService.setup(x => x.lockUser('id1')) + .returns(() => of({})); + + usersState.select('id1').subscribe(); + usersState.lock(oldUsers[0]).subscribe(); + + const user_1 = usersState.snapshot.users.at(0); + + expect(user_1.user.isLocked).toBeTruthy(); + expect(user_1).toBe(usersState.snapshot.selectedUser!); + }); + + it('should unmark as locked when unlocked', () => { + usersService.setup(x => x.unlockUser('id2')) + .returns(() => of({})); + + usersState.select('id2').subscribe(); + usersState.unlock(oldUsers[1]).subscribe(); + + const user_1 = usersState.snapshot.users.at(1); + + expect(user_1.user.isLocked).toBeFalsy(); + expect(user_1).toBe(usersState.snapshot.selectedUser!); + }); + + it('should update user properties when updated', () => { + const request = new UpdateUserDto('new@mail.com', 'New'); + + usersService.setup(x => x.putUser('id1', request)) + .returns(() => of({})); + + usersState.select('id1').subscribe(); + usersState.update(oldUsers[0], request).subscribe(); + + const user_1 = usersState.snapshot.users.at(0); + + expect(user_1.user.email).toEqual('new@mail.com'); + expect(user_1.user.displayName).toEqual('New'); + expect(user_1).toBe(usersState.snapshot.selectedUser!); + }); + + it('should add user to snapshot when created', () => { + const request = new CreateUserDto(newUser.email, newUser.displayName, 'password'); + + usersService.setup(x => x.postUser(request)) + .returns(() => of(newUser)); + + usersState.create(request).subscribe(); + + expect(usersState.snapshot.users.values).toEqual([ + { isCurrentUser: false, user: newUser }, + { isCurrentUser: false, user: oldUsers[0] }, + { isCurrentUser: true, user: oldUsers[1] } + ]); + expect(usersState.snapshot.usersPager.numberOfItems).toBe(201); + }); + + it('should load next page and prev page when paging', () => { + usersService.setup(x => x.getUsers(10, 10, undefined)) + .returns(() => of(new UsersDto(200, []))); + + usersState.goNext().subscribe(); + usersState.goPrev().subscribe(); + + expect().nothing(); + + usersService.verify(x => x.getUsers(10, 10, undefined), Times.once()); + usersService.verify(x => x.getUsers(10, 0, undefined), Times.exactly(2)); + }); + + it('should load with query when searching', () => { + usersService.setup(x => x.getUsers(10, 0, 'my-query')) + .returns(() => of(new UsersDto(0, []))); + + usersState.search('my-query').subscribe(); + + expect(usersState.snapshot.usersQuery).toEqual('my-query'); + + usersService.verify(x => x.getUsers(10, 0, 'my-query'), Times.once()); + }); +}); \ No newline at end of file diff --git a/src/Squidex/app/features/administration/state/users.state.ts b/src/Squidex/app/features/administration/state/users.state.ts new file mode 100644 index 000000000..1caad1047 --- /dev/null +++ b/src/Squidex/app/features/administration/state/users.state.ts @@ -0,0 +1,255 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { Injectable } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Observable, of } from 'rxjs'; +import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'; + +import '@app/framework/utils/rxjs-extensions'; + +import { + AuthService, + DialogService, + Form, + ImmutableArray, + notify, + Pager, + State, + ValidatorsEx +} from '@app/shared'; + +import { + CreateUserDto, + UpdateUserDto, + UserDto, + UsersService +} from './../services/users.service'; + +export class UserForm extends Form { + constructor( + formBuilder: FormBuilder + ) { + super(formBuilder.group({ + email: ['', + [ + Validators.email, + Validators.required, + Validators.maxLength(100) + ] + ], + displayName: ['', + [ + Validators.required, + Validators.maxLength(100) + ] + ], + password: ['', + [ + Validators.nullValidator + ] + ], + passwordConfirm: ['', + [ + ValidatorsEx.match('password', 'Passwords must be the same.') + ] + ] + })); + } + + public load(user?: UserDto) { + if (user) { + this.form.controls['password'].setValidators(null); + } else { + this.form.controls['password'].setValidators(Validators.required); + } + + super.load(user); + } +} + +interface SnapshotUser { + user: UserDto; + + isCurrentUser: boolean; +} + +interface Snapshot { + users: ImmutableArray; + usersPager: Pager; + usersQuery?: string; + + isLoaded?: boolean; + + selectedUser?: SnapshotUser; +} + +@Injectable() +export class UsersState extends State { + public users = + this.changes.pipe(map(x => x.users), + distinctUntilChanged()); + + public usersPager = + this.changes.pipe(map(x => x.usersPager), + distinctUntilChanged()); + + public selectedUser = + this.changes.pipe(map(x => x.selectedUser), + distinctUntilChanged()); + + public isLoaded = + this.changes.pipe(map(x => !!x.isLoaded), + distinctUntilChanged()); + + constructor( + private readonly authState: AuthService, + private readonly dialogs: DialogService, + private readonly usersService: UsersService + ) { + super({ users: ImmutableArray.empty(), usersPager: new Pager(0) }); + } + + public select(id: string | null): Observable { + return this.loadUser(id).pipe( + tap(selectedUser => { + this.next(s => ({ ...s, selectedUser })); + }), + map(x => x && x.user)); + } + + private loadUser(id: string | null) { + return !id ? + of(null) : + of(this.snapshot.users.find(x => x.user.id === id)).pipe( + switchMap(user => { + if (!user) { + return this.usersService.getUser(id).pipe(map(x => this.createUser(x)), catchError(() => of(null))); + } else { + return of(user); + } + })); + } + + public load(isReload = false): Observable { + if (!isReload) { + this.resetState(); + } + + return this.loadInternal(isReload); + } + + private loadInternal(isReload = false): Observable { + return this.usersService.getUsers( + this.snapshot.usersPager.pageSize, + this.snapshot.usersPager.skip, + this.snapshot.usersQuery).pipe( + tap(dtos => { + if (isReload) { + this.dialogs.notifyInfo('Users reloaded.'); + } + + this.next(s => { + const users = ImmutableArray.of(dtos.items.map(x => this.createUser(x))); + const usersPager = s.usersPager.setCount(dtos.total); + + let selectedUser = s.selectedUser; + + if (selectedUser) { + selectedUser = users.find(x => x.user.id === selectedUser!.user.id) || selectedUser; + } + + return { ...s, users, usersPager, selectedUser, isLoaded: true }; + }); + }), + notify(this.dialogs)); + } + + public create(request: CreateUserDto): Observable { + return this.usersService.postUser(request).pipe( + tap(dto => { + this.next(s => { + const users = s.users.pushFront(this.createUser(dto)); + const usersPager = s.usersPager.incrementCount(); + + return { ...s, users, usersPager }; + }); + })); + } + + public update(user: UserDto, request: UpdateUserDto): Observable { + return this.usersService.putUser(user.id, request).pipe( + tap(() => { + this.replaceUser(update(user, request)); + })); + } + + public lock(user: UserDto): Observable { + return this.usersService.lockUser(user.id).pipe( + tap(() => { + this.replaceUser(setLocked(user, true)); + }), + notify(this.dialogs)); + } + + public unlock(user: UserDto): Observable { + return this.usersService.unlockUser(user.id).pipe( + tap(() => { + this.replaceUser(setLocked(user, false)); + }), + notify(this.dialogs)); + } + + public search(query: string): Observable { + this.next(s => ({ ...s, usersPager: new Pager(0), usersQuery: query })); + + return this.loadInternal(); + } + + public goNext(): Observable { + this.next(s => ({ ...s, usersPager: s.usersPager.goNext() })); + + return this.loadInternal(); + } + + public goPrev(): Observable { + this.next(s => ({ ...s, usersPager: s.usersPager.goPrev() })); + + return this.loadInternal(); + } + + private replaceUser(user: UserDto) { + return this.next(s => { + const users = s.users.map(u => u.user.id === user.id ? this.createUser(user, u) : u); + + const selectedUser = s.selectedUser && s.selectedUser.user.id === user.id ? users.find(x => x.user.id === user.id) : s.selectedUser; + + return { ...s, users, selectedUser }; + }); + } + + private get userId() { + return this.authState.user!.id; + } + + private createUser(user: UserDto, current?: SnapshotUser): SnapshotUser { + if (!user) { + return null!; + } else if (current && current.user === user) { + return current; + } else { + return { user, isCurrentUser: user.id === this.userId }; + } + } +} + + +const update = (user: UserDto, request: UpdateUserDto) => + user.with(request); + +const setLocked = (user: UserDto, isLocked: boolean) => + user.with({ isLocked }); \ No newline at end of file diff --git a/src/Squidex/app/features/api/api-area.component.html b/src/Squidex/app/features/api/api-area.component.html index 59c48ccf6..fae5e792b 100644 --- a/src/Squidex/app/features/api/api-area.component.html +++ b/src/Squidex/app/features/api/api-area.component.html @@ -1,33 +1,25 @@ - + -
-
-

API

-
+ + API + - - - -
- -
- -
+ + +
\ No newline at end of file diff --git a/src/Squidex/app/features/api/api-area.component.ts b/src/Squidex/app/features/api/api-area.component.ts index 093dee1c4..6e9e538cc 100644 --- a/src/Squidex/app/features/api/api-area.component.ts +++ b/src/Squidex/app/features/api/api-area.component.ts @@ -7,19 +7,16 @@ import { Component } from '@angular/core'; -import { AppContext } from 'shared'; +import { AppsState } from '@app/shared'; @Component({ selector: 'sqx-api-area', styleUrls: ['./api-area.component.scss'], - templateUrl: './api-area.component.html', - providers: [ - AppContext - ] + templateUrl: './api-area.component.html' }) export class ApiAreaComponent { constructor( - public readonly ctx: AppContext + public readonly appsState: AppsState ) { } } \ No newline at end of file diff --git a/src/Squidex/app/features/api/module.ts b/src/Squidex/app/features/api/module.ts index 00d6dda1f..f4701b425 100644 --- a/src/Squidex/app/features/api/module.ts +++ b/src/Squidex/app/features/api/module.ts @@ -7,12 +7,11 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; -import { DndModule } from 'ng2-dnd'; import { SqxFrameworkModule, SqxSharedModule -} from 'shared'; +} from '@app/shared'; import { ApiAreaComponent, @@ -37,7 +36,6 @@ const routes: Routes = [ @NgModule({ imports: [ - DndModule, SqxFrameworkModule, SqxSharedModule, RouterModule.forChild(routes) diff --git a/src/Squidex/app/features/api/pages/graphql/graphql-page.component.html b/src/Squidex/app/features/api/pages/graphql/graphql-page.component.html index ff2fc5573..74f0d3054 100644 --- a/src/Squidex/app/features/api/pages/graphql/graphql-page.component.html +++ b/src/Squidex/app/features/api/pages/graphql/graphql-page.component.html @@ -1,5 +1,5 @@ - + - -
+ +
\ No newline at end of file diff --git a/src/Squidex/app/features/api/pages/graphql/graphql-page.component.scss b/src/Squidex/app/features/api/pages/graphql/graphql-page.component.scss index afdde04bc..6d3c84b1c 100644 --- a/src/Squidex/app/features/api/pages/graphql/graphql-page.component.scss +++ b/src/Squidex/app/features/api/pages/graphql/graphql-page.component.scss @@ -1,17 +1,25 @@ @import '_vars'; @import '_mixins'; -@import '~graphiql/graphiql'; +:host /deep/ { + @import '~graphiql/graphiql'; -.graphiql-container { - @include absolute(0, 0, 0, 0); -} + .graphiql-container { + & { + @include absolute(0, 0, 0, 0); + } -.graphiql-container > * { - box-sizing: content-box; + & * { + box-sizing: content-box; + } - // sass-lint:disable class-name-format - & .editorWrap { - overflow: hidden; + // sass-lint:disable class-name-format + & .editorWrap { + overflow: hidden; + } + } + + .CodeMirror-linenumbers { + min-width: 29px; } } \ No newline at end of file diff --git a/src/Squidex/app/features/api/pages/graphql/graphql-page.component.ts b/src/Squidex/app/features/api/pages/graphql/graphql-page.component.ts index c410caaeb..8c59c1a4b 100644 --- a/src/Squidex/app/features/api/pages/graphql/graphql-page.component.ts +++ b/src/Squidex/app/features/api/pages/graphql/graphql-page.component.ts @@ -5,58 +5,45 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { Component, ElementRef, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; -import { Observable } from 'rxjs'; +import { AfterViewInit, Component, ElementRef, ViewChild } from '@angular/core'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; import * as React from 'react'; import * as ReactDOM from 'react-dom'; const GraphiQL = require('graphiql'); -/* tslint:disable:use-view-encapsulation */ - -import { - AppContext, - GraphQlService, - LocalStoreService -} from 'shared'; +import { AppsState, GraphQlService } from '@app/shared'; @Component({ selector: 'sqx-graphql-page', styleUrls: ['./graphql-page.component.scss'], - templateUrl: './graphql-page.component.html', - providers: [ - AppContext - ], - encapsulation: ViewEncapsulation.None + templateUrl: './graphql-page.component.html' }) -export class GraphQLPageComponent implements OnInit { +export class GraphQLPageComponent implements AfterViewInit { @ViewChild('graphiQLContainer') public graphiQLContainer: ElementRef; - constructor(public readonly ctx: AppContext, - private readonly graphQlService: GraphQlService, - private readonly localStoreService: LocalStoreService + constructor( + public readonly appsState: AppsState, + private readonly graphQlService: GraphQlService ) { } - public ngOnInit() { + public ngAfterViewInit() { ReactDOM.render( React.createElement(GraphiQL, { fetcher: (params: any) => { return this.request(params); - }, - onEditQuery: (query: string) => { - this.localStoreService.set('graphiQlQuery', query); - }, - query: this.localStoreService.get('graphiQlQuery') + } }), this.graphiQLContainer.nativeElement ); } private request(params: any) { - return this.graphQlService.query(this.ctx.appName, params).catch(response => Observable.of(response.error)).toPromise(); + return this.graphQlService.query(this.appsState.appName, params).pipe(catchError(response => of(response.error))).toPromise(); } } diff --git a/src/Squidex/app/features/apps/module.ts b/src/Squidex/app/features/apps/module.ts index 467e2c61b..69c393429 100644 --- a/src/Squidex/app/features/apps/module.ts +++ b/src/Squidex/app/features/apps/module.ts @@ -8,7 +8,7 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; -import { SqxFrameworkModule, SqxSharedModule } from 'shared'; +import { SqxFrameworkModule, SqxSharedModule } from '@app/shared'; import { AppsPageComponent, diff --git a/src/Squidex/app/features/apps/pages/apps-page.component.html b/src/Squidex/app/features/apps/pages/apps-page.component.html index ea2f3b699..21578f636 100644 --- a/src/Squidex/app/features/apps/pages/apps-page.component.html +++ b/src/Squidex/app/features/apps/pages/apps-page.component.html @@ -1,28 +1,30 @@ 
-

Hi {{ctx.user.displayName}}

+

Hi {{authState.user?.displayName}}

Welcome to Squidex.
-
-
-

You are not collaborating to any app yet

-
+ +
+
+

You are not collaborating to any app yet

+
-
-
-

{{app.name}}

+
+
+

{{app.name}}

-
- Edit +
+ Edit +
-
+
@@ -49,7 +51,9 @@
Start with our ready to use blog.
-
Sample Code: ASP.NET Core
+
+ Sample Code: C# +
@@ -64,34 +68,37 @@
Create your profile page.
-
Sample Code: ASP.NET Core
+
+ Sample Code: C# +
-
-