Browse Source

Client handling improved

pull/1/head
Sebastian 9 years ago
parent
commit
55ee4dd491
  1. 8
      src/Squidex.Events/Apps/AppClientAttached.cs
  2. 6
      src/Squidex.Events/Apps/AppClientRevoked.cs
  3. 2
      src/Squidex.Events/Apps/AppContributorAssigned.cs
  4. 2
      src/Squidex.Events/Apps/AppContributorRemoved.cs
  5. 2
      src/Squidex.Events/Apps/AppCreated.cs
  6. 2
      src/Squidex.Events/Apps/AppLanguagesConfigured.cs
  7. 2
      src/Squidex.Events/Schemas/FieldDeleted.cs
  8. 27
      src/Squidex.Infrastructure/CQRS/Commands/CommandContext.cs
  9. 4
      src/Squidex.Infrastructure/CQRS/Commands/InMemoryCommandBus.cs
  10. 2
      src/Squidex.Infrastructure/Dispatching/FuncContextDispatcher.cs
  11. 6
      src/Squidex.Read/Apps/IAppClientEntity.cs
  12. 2
      src/Squidex.Read/Apps/IAppEntity.cs
  13. 4
      src/Squidex.Read/Apps/Services/Implementations/CachingAppProvider.cs
  14. 8
      src/Squidex.Store.MongoDb/Apps/MongoAppClientEntity.cs
  15. 8
      src/Squidex.Store.MongoDb/Apps/MongoAppEntity.cs
  16. 8
      src/Squidex.Store.MongoDb/Apps/MongoAppRepository.cs
  17. 6
      src/Squidex.Store.MongoDb/Schemas/Models/FieldModel.cs
  18. 10
      src/Squidex.Store.MongoDb/Schemas/Models/SchemaModel.cs
  19. 2
      src/Squidex.Store.MongoDb/Schemas/MongoSchemaEntity.cs
  20. 4
      src/Squidex.Store.MongoDb/Schemas/MongoSchemaRepository.cs
  21. 33
      src/Squidex.Write/Apps/AppClient.cs
  22. 35
      src/Squidex.Write/Apps/AppCommandHandler.cs
  23. 109
      src/Squidex.Write/Apps/AppDomainObject.cs
  24. 10
      src/Squidex.Write/Apps/Commands/AttachClient.cs
  25. 8
      src/Squidex.Write/Apps/Commands/CreateApp.cs
  26. 10
      src/Squidex.Write/Apps/Commands/RevokeClient.cs
  27. 2
      src/Squidex/Configurations/Identity/LazyClientStore.cs
  28. 17
      src/Squidex/Configurations/Swagger/SwaggerUsage.cs
  29. 53
      src/Squidex/Configurations/Swagger/XmlResponseTypesProcessor.cs
  30. 54
      src/Squidex/Modules/Api/Apps/AppClientsController.cs
  31. 28
      src/Squidex/Modules/Api/Apps/AppContributorsController.cs
  32. 15
      src/Squidex/Modules/Api/Apps/AppController.cs
  33. 8
      src/Squidex/Modules/Api/Apps/AppLanguagesController.cs
  34. 9
      src/Squidex/Modules/Api/Apps/Models/AttachClientDto.cs
  35. 12
      src/Squidex/Modules/Api/Apps/Models/ClientDto.cs
  36. 2
      src/Squidex/Modules/Api/Apps/Models/CreateAppDto.cs
  37. 4
      src/Squidex/Modules/Api/Languages/LanguagesController.cs
  38. 23
      src/Squidex/Modules/Api/Schemas/Models/CreateFieldDto.cs
  39. 9
      src/Squidex/Modules/Api/Schemas/Models/CreateSchemaDto.cs
  40. 52
      src/Squidex/Modules/Api/Schemas/Models/FieldDto.cs
  41. 33
      src/Squidex/Modules/Api/Schemas/Models/Fields/NumberField.cs
  42. 38
      src/Squidex/Modules/Api/Schemas/Models/Fields/StringField.cs
  43. 57
      src/Squidex/Modules/Api/Schemas/Models/SchemaDetailsDto.cs
  44. 17
      src/Squidex/Modules/Api/Schemas/Models/SchemaDto.cs
  45. 17
      src/Squidex/Modules/Api/Schemas/Models/UpdateFieldDto.cs
  46. 14
      src/Squidex/Modules/Api/Schemas/Models/UpdateSchemaDto.cs
  47. 177
      src/Squidex/Modules/Api/Schemas/SchemaFieldsController.cs
  48. 75
      src/Squidex/Modules/Api/Schemas/SchemasController.cs
  49. 2
      src/Squidex/Modules/Api/Users/Models/UserDto.cs
  50. 8
      src/Squidex/Modules/Api/Users/UsersController.cs
  51. 4
      src/Squidex/app/app.module.ts
  52. 6
      src/Squidex/app/app.routes.ts
  53. 2
      src/Squidex/app/components/internal/app/left-menu.component.html
  54. 91
      src/Squidex/app/components/internal/app/settings/clients-page.component.html
  55. 44
      src/Squidex/app/components/internal/app/settings/clients-page.component.scss
  56. 107
      src/Squidex/app/components/internal/app/settings/clients-page.component.ts
  57. 6
      src/Squidex/app/components/internal/app/settings/contributors-page.component.html
  58. 6
      src/Squidex/app/components/internal/app/settings/contributors-page.component.scss
  59. 2
      src/Squidex/app/components/internal/app/settings/contributors-page.component.ts
  60. 40
      src/Squidex/app/components/internal/app/settings/credentials-page.component.html
  61. 17
      src/Squidex/app/components/internal/app/settings/credentials-page.component.scss
  62. 60
      src/Squidex/app/components/internal/app/settings/credentials-page.component.ts
  63. 10
      src/Squidex/app/components/internal/app/settings/languages-page.component.html
  64. 6
      src/Squidex/app/components/internal/app/settings/languages-page.component.scss
  65. 2
      src/Squidex/app/components/internal/declarations.ts
  66. 4
      src/Squidex/app/components/internal/module.ts
  67. 5
      src/Squidex/app/components/layout/app-form.component.html
  68. 8
      src/Squidex/app/components/layout/app-form.component.ts
  69. 2
      src/Squidex/app/shared/index.ts
  70. 52
      src/Squidex/app/shared/services/app-client-keys.service.ts
  71. 107
      src/Squidex/app/shared/services/app-clients.service.spec.ts
  72. 67
      src/Squidex/app/shared/services/app-clients.service.ts
  73. 5
      src/Squidex/app/shared/services/app-contributors.service.ts
  74. 6
      src/Squidex/app/shared/services/app-languages.service.ts
  75. 5
      src/Squidex/app/shared/services/apps.service.ts
  76. 5
      src/Squidex/app/shared/services/languages.service.ts
  77. 16
      src/Squidex/app/shared/services/users.service.ts
  78. 14
      src/Squidex/app/theme/_bootstrap.scss
  79. 1
      src/Squidex/app/theme/_vars.scss
  80. 2
      src/Squidex/web.config
  81. 97
      tests/Squidex.Infrastructure.Tests/CQRS/Commands/CommandContextTests.cs
  82. 76
      tests/Squidex.Write.Tests/Apps/AppCommandHandlerTests.cs
  83. 54
      tests/Squidex.Write.Tests/Apps/AppDomainObjectTests.cs

8
src/Squidex.Events/Apps/AppClientKeyCreated.cs → src/Squidex.Events/Apps/AppClientAttached.cs

@ -12,10 +12,12 @@ using Squidex.Infrastructure.CQRS.Events;
namespace Squidex.Events.Apps namespace Squidex.Events.Apps
{ {
[TypeName("AppClientKeyCreated")] [TypeName("AppClientAttachedEvent")]
public sealed class AppClientKeyCreated : IEvent public sealed class AppClientAttached : IEvent
{ {
public string ClientKey { get; set; } public string ClientName { get; set; }
public string ClientSecret { get; set; }
public DateTime ExpiresUtc { get; set; } public DateTime ExpiresUtc { get; set; }
} }

6
src/Squidex.Events/Apps/AppClientKeyRevoked.cs → src/Squidex.Events/Apps/AppClientRevoked.cs

@ -12,10 +12,10 @@ using Squidex.Infrastructure.CQRS.Events;
namespace Squidex.Events.Apps namespace Squidex.Events.Apps
{ {
[TypeName("AppClientKeyRevoked")] [TypeName("AppClientRevokedEvent")]
public sealed class AppClientKeyRevoked : IEvent public sealed class AppClientRevoked : IEvent
{ {
public string ClientKey { get; set; } public string ClientName { get; set; }
public DateTime ExpiresUtc { get; set; } public DateTime ExpiresUtc { get; set; }
} }

2
src/Squidex.Events/Apps/AppContributorAssigned.cs

@ -12,7 +12,7 @@ using Squidex.Infrastructure.CQRS.Events;
namespace Squidex.Events.Apps namespace Squidex.Events.Apps
{ {
[TypeName("AppContributorAssigned")] [TypeName("AppContributorAssignedEvent")]
public class AppContributorAssigned : IEvent public class AppContributorAssigned : IEvent
{ {
public string ContributorId { get; set; } public string ContributorId { get; set; }

2
src/Squidex.Events/Apps/AppContributorRemoved.cs

@ -11,7 +11,7 @@ using Squidex.Infrastructure.CQRS.Events;
namespace Squidex.Events.Apps namespace Squidex.Events.Apps
{ {
[TypeName("AppContributorRemoved")] [TypeName("AppContributorRemovedEvent")]
public class AppContributorRemoved : IEvent public class AppContributorRemoved : IEvent
{ {
public string ContributorId { get; set; } public string ContributorId { get; set; }

2
src/Squidex.Events/Apps/AppCreated.cs

@ -11,7 +11,7 @@ using Squidex.Infrastructure.CQRS.Events;
namespace Squidex.Events.Apps namespace Squidex.Events.Apps
{ {
[TypeName("AppCreated")] [TypeName("AppCreatedEvent")]
public class AppCreated : IEvent public class AppCreated : IEvent
{ {
public string Name { get; set; } public string Name { get; set; }

2
src/Squidex.Events/Apps/AppLanguagesConfigured.cs

@ -12,7 +12,7 @@ using Squidex.Infrastructure.CQRS.Events;
namespace Squidex.Events.Apps namespace Squidex.Events.Apps
{ {
[TypeName("AppLanguagesConfigured")] [TypeName("AppLanguagesConfiguredEvent")]
public sealed class AppLanguagesConfigured : IEvent public sealed class AppLanguagesConfigured : IEvent
{ {
public List<Language> Languages { get; set; } public List<Language> Languages { get; set; }

2
src/Squidex.Events/Schemas/FieldDeleted.cs

@ -11,7 +11,7 @@ using Squidex.Infrastructure.CQRS.Events;
namespace Squidex.Events.Schemas namespace Squidex.Events.Schemas
{ {
[TypeName("FieldDeletedEvent")] [TypeName("FieldDeleted")]
public class FieldDeleted : IEvent public class FieldDeleted : IEvent
{ {
public long FieldId { get; set; } public long FieldId { get; set; }

27
src/Squidex.Infrastructure/CQRS/Commands/CommandContext.cs

@ -14,7 +14,7 @@ namespace Squidex.Infrastructure.CQRS.Commands
{ {
private readonly ICommand command; private readonly ICommand command;
private Exception exception; private Exception exception;
private bool isSucceeded; private Tuple<object> result;
public ICommand Command public ICommand Command
{ {
@ -23,12 +23,12 @@ namespace Squidex.Infrastructure.CQRS.Commands
public bool IsHandled public bool IsHandled
{ {
get { return isSucceeded || exception != null; } get { return result != null || exception != null; }
} }
public bool IsSucceeded public bool IsSucceeded
{ {
get { return isSucceeded; } get { return result != null; }
} }
public Exception Exception public Exception Exception
@ -43,14 +43,29 @@ namespace Squidex.Infrastructure.CQRS.Commands
this.command = command; this.command = command;
} }
public void MarkSucceeded() public void Succeed(object resultValue = null)
{ {
isSucceeded = true; if (IsHandled)
{
return;
}
result = Tuple.Create(resultValue);
} }
public void MarkFailed(Exception handlerException) public void Fail(Exception handlerException)
{ {
if (IsHandled)
{
return;
}
exception = handlerException; exception = handlerException;
} }
public T Result<T>()
{
return result != null ? (T)result.Item1 : default(T);
}
} }
} }

4
src/Squidex.Infrastructure/CQRS/Commands/InMemoryCommandBus.cs

@ -37,12 +37,12 @@ namespace Squidex.Infrastructure.CQRS.Commands
if (isHandled) if (isHandled)
{ {
context.MarkSucceeded(); context.Succeed();
} }
} }
catch (Exception e) catch (Exception e)
{ {
context.MarkFailed(e); context.Fail(e);
} }
} }

2
src/Squidex.Infrastructure/Dispatching/FuncContextDispatcher.cs

@ -21,7 +21,7 @@ namespace Squidex.Infrastructure.Dispatching
{ {
Handlers = Handlers =
typeof(TTarget) typeof(TTarget)
.GetMethods() .GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
.Where(Helper.HasRightName) .Where(Helper.HasRightName)
.Where(Helper.HasRightParameters<TIn, TContext>) .Where(Helper.HasRightParameters<TIn, TContext>)
.Where(Helper.HasRightReturnType<TOut>) .Where(Helper.HasRightReturnType<TOut>)

6
src/Squidex.Read/Apps/IAppClientKeyEntity.cs → src/Squidex.Read/Apps/IAppClientEntity.cs

@ -10,9 +10,11 @@ using System;
namespace Squidex.Read.Apps namespace Squidex.Read.Apps
{ {
public interface IAppClientKeyEntity public interface IAppClientEntity
{ {
string ClientKey { get; } string ClientName { get; }
string ClientSecret { get; }
DateTime ExpiresUtc { get; } DateTime ExpiresUtc { get; }
} }

2
src/Squidex.Read/Apps/IAppEntity.cs

@ -17,7 +17,7 @@ namespace Squidex.Read.Apps
IEnumerable<Language> Languages { get; } IEnumerable<Language> Languages { get; }
IEnumerable<IAppClientKeyEntity> ClientKeys { get; } IEnumerable<IAppClientEntity> Clients { get; }
IEnumerable<IAppContributorEntity> Contributors { get; } IEnumerable<IAppContributorEntity> Contributors { get; }
} }

4
src/Squidex.Read/Apps/Services/Implementations/CachingAppProvider.cs

@ -66,8 +66,8 @@ namespace Squidex.Read.Apps.Services.Implementations
{ {
if (@event.Payload is AppContributorAssigned || if (@event.Payload is AppContributorAssigned ||
@event.Payload is AppContributorRemoved || @event.Payload is AppContributorRemoved ||
@event.Payload is AppClientKeyCreated || @event.Payload is AppClientAttached ||
@event.Payload is AppClientKeyRevoked || @event.Payload is AppClientRevoked ||
@event.Payload is AppLanguagesConfigured) @event.Payload is AppLanguagesConfigured)
{ {
var appName = Cache.Get<string>(BuildNamesCacheKey(@event.Headers.AggregateId())); var appName = Cache.Get<string>(BuildNamesCacheKey(@event.Headers.AggregateId()));

8
src/Squidex.Store.MongoDb/Apps/MongoAppClientKeyEntity.cs → src/Squidex.Store.MongoDb/Apps/MongoAppClientEntity.cs

@ -12,11 +12,15 @@ using Squidex.Read.Apps;
namespace Squidex.Store.MongoDb.Apps namespace Squidex.Store.MongoDb.Apps
{ {
public class MongoAppClientKeyEntity : IAppClientKeyEntity public sealed class MongoAppClientEntity : IAppClientEntity
{ {
[BsonRequired] [BsonRequired]
[BsonElement] [BsonElement]
public string ClientKey { get; set; } public string ClientName { get; set; }
[BsonRequired]
[BsonElement]
public string ClientSecret { get; set; }
[BsonRequired] [BsonRequired]
[BsonElement] [BsonElement]

8
src/Squidex.Store.MongoDb/Apps/MongoAppEntity.cs

@ -27,7 +27,7 @@ namespace Squidex.Store.MongoDb.Apps
[BsonRequired] [BsonRequired]
[BsonElement] [BsonElement]
public List<MongoAppClientKeyEntity> ClientKeys { get; set; } public List<MongoAppClientEntity> Clients { get; set; }
[BsonRequired] [BsonRequired]
[BsonElement] [BsonElement]
@ -38,9 +38,9 @@ namespace Squidex.Store.MongoDb.Apps
get { return Languages.Select(Language.GetLanguage); } get { return Languages.Select(Language.GetLanguage); }
} }
IEnumerable<IAppClientKeyEntity> IAppEntity.ClientKeys IEnumerable<IAppClientEntity> IAppEntity.Clients
{ {
get { return ClientKeys; } get { return Clients; }
} }
IEnumerable<IAppContributorEntity> IAppEntity.Contributors IEnumerable<IAppContributorEntity> IAppEntity.Contributors
@ -52,7 +52,7 @@ namespace Squidex.Store.MongoDb.Apps
{ {
Contributors = new List<MongoAppContributorEntity>(); Contributors = new List<MongoAppContributorEntity>();
ClientKeys = new List<MongoAppClientKeyEntity>(); Clients = new List<MongoAppClientEntity>();
} }
} }
} }

8
src/Squidex.Store.MongoDb/Apps/MongoAppRepository.cs

@ -69,14 +69,14 @@ namespace Squidex.Store.MongoDb.Apps
return Collection.UpdateAsync(headers, a => a.Languages = @event.Languages.Select(x => x.Iso2Code).ToList()); return Collection.UpdateAsync(headers, a => a.Languages = @event.Languages.Select(x => x.Iso2Code).ToList());
} }
public Task On(AppClientKeyCreated @event, EnvelopeHeaders headers) public Task On(AppClientAttached @event, EnvelopeHeaders headers)
{ {
return Collection.UpdateAsync(headers, a => a.ClientKeys.Add(SimpleMapper.Map(@event, new MongoAppClientKeyEntity()))); return Collection.UpdateAsync(headers, a => a.Clients.Add(SimpleMapper.Map(@event, new MongoAppClientEntity())));
} }
public Task On(AppClientKeyRevoked @event, EnvelopeHeaders headers) public Task On(AppClientRevoked @event, EnvelopeHeaders headers)
{ {
return Collection.UpdateAsync(headers, a => a.ClientKeys.RemoveAll(c => c.ClientKey == @event.ClientKey)); return Collection.UpdateAsync(headers, a => a.Clients.RemoveAll(c => c.ClientName == @event.ClientName));
} }
public Task On(AppContributorAssigned @event, EnvelopeHeaders headers) public Task On(AppContributorAssigned @event, EnvelopeHeaders headers)

6
src/Squidex.Store.MongoDb/Schemas/Models/FieldDto.cs → src/Squidex.Store.MongoDb/Schemas/Models/FieldModel.cs

@ -10,7 +10,7 @@ using Squidex.Core.Schemas;
namespace Squidex.Store.MongoDb.Schemas.Models namespace Squidex.Store.MongoDb.Schemas.Models
{ {
public class FieldDto public class FieldModel
{ {
public string Name { get; set; } public string Name { get; set; }
@ -20,9 +20,9 @@ namespace Squidex.Store.MongoDb.Schemas.Models
public FieldProperties Properties { get; set; } public FieldProperties Properties { get; set; }
public static FieldDto Create(Field field) public static FieldModel Create(Field field)
{ {
return new FieldDto return new FieldModel
{ {
Name = field.Name, Name = field.Name,
IsHidden = field.IsHidden, IsHidden = field.IsHidden,

10
src/Squidex.Store.MongoDb/Schemas/Models/SchemaDto.cs → src/Squidex.Store.MongoDb/Schemas/Models/SchemaModel.cs

@ -16,24 +16,24 @@ using Squidex.Infrastructure;
namespace Squidex.Store.MongoDb.Schemas.Models namespace Squidex.Store.MongoDb.Schemas.Models
{ {
public sealed class SchemaDto public sealed class SchemaModel
{ {
public string Name { get; set; } public string Name { get; set; }
public Dictionary<long, FieldDto> Fields { get; set; } public Dictionary<long, FieldModel> Fields { get; set; }
public SchemaProperties Properties { get; set; } public SchemaProperties Properties { get; set; }
public static SchemaDto Create(Schema schema) public static SchemaModel Create(Schema schema)
{ {
Guard.NotNull(schema, nameof(schema)); Guard.NotNull(schema, nameof(schema));
var dto = new SchemaDto { Properties = schema.Properties, Name = schema.Name }; var dto = new SchemaModel { Properties = schema.Properties, Name = schema.Name };
dto.Fields = dto.Fields =
schema.Fields.ToDictionary( schema.Fields.ToDictionary(
kvp => kvp.Key, kvp => kvp.Key,
kvp => FieldDto.Create(kvp.Value)); kvp => FieldModel.Create(kvp.Value));
return dto; return dto;
} }

2
src/Squidex.Store.MongoDb/Schemas/MongoSchemaEntity.cs

@ -49,7 +49,7 @@ namespace Squidex.Store.MongoDb.Schemas
return; return;
} }
var dto = Schema.ToJsonObject<SchemaDto>(serializerSettings); var dto = Schema.ToJsonObject<SchemaModel>(serializerSettings);
schema = dto?.ToSchema(fieldRegistry); schema = dto?.ToSchema(fieldRegistry);
} }

4
src/Squidex.Store.MongoDb/Schemas/MongoSchemaRepository.cs

@ -161,14 +161,14 @@ namespace Squidex.Store.MongoDb.Schemas
private void Serialize(MongoSchemaEntity entity, Schema schema) private void Serialize(MongoSchemaEntity entity, Schema schema)
{ {
var dto = SchemaDto.Create(schema); var dto = SchemaModel.Create(schema);
entity.Schema = dto.ToJsonBsonDocument(serializerSettings); entity.Schema = dto.ToJsonBsonDocument(serializerSettings);
} }
private Schema Deserialize(MongoSchemaEntity entity) private Schema Deserialize(MongoSchemaEntity entity)
{ {
var dto = entity?.Schema.ToJsonObject<SchemaDto>(serializerSettings); var dto = entity?.Schema.ToJsonObject<SchemaModel>(serializerSettings);
return dto?.ToSchema(fieldRegistry); return dto?.ToSchema(fieldRegistry);
} }

33
src/Squidex.Write/Apps/AppClient.cs

@ -0,0 +1,33 @@
// ==========================================================================
// AppClient.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
using System;
using Squidex.Infrastructure;
namespace Squidex.Write.Apps
{
public sealed class AppClient
{
public string ClientName { get; }
public string ClientSecret { get; }
public DateTime ExpiresUtc { get; }
public AppClient(string name, string secret, DateTime expiresUtc)
{
Guard.NotNullOrEmpty(name, nameof(name));
Guard.NotNullOrEmpty(secret, nameof(secret));
ClientName = name;
ClientSecret = secret;
ExpiresUtc = expiresUtc;
}
}
}

35
src/Squidex.Write/Apps/AppCommandHandler.cs

@ -20,22 +20,26 @@ namespace Squidex.Write.Apps
{ {
private readonly IAppRepository appRepository; private readonly IAppRepository appRepository;
private readonly IUserRepository userRepository; private readonly IUserRepository userRepository;
private readonly ClientKeyGenerator keyGenerator;
public AppCommandHandler( public AppCommandHandler(
IDomainObjectFactory domainObjectFactory, IDomainObjectFactory domainObjectFactory,
IDomainObjectRepository domainObjectRepository, IDomainObjectRepository domainObjectRepository,
IUserRepository userRepository,
IAppRepository appRepository, IAppRepository appRepository,
IUserRepository userRepository) ClientKeyGenerator keyGenerator)
: base(domainObjectFactory, domainObjectRepository) : base(domainObjectFactory, domainObjectRepository)
{ {
Guard.NotNull(keyGenerator, nameof(keyGenerator));
Guard.NotNull(appRepository, nameof(appRepository)); Guard.NotNull(appRepository, nameof(appRepository));
Guard.NotNull(userRepository, nameof(userRepository)); Guard.NotNull(userRepository, nameof(userRepository));
this.keyGenerator = keyGenerator;
this.appRepository = appRepository; this.appRepository = appRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
} }
public Task On(CreateApp command) protected Task On(CreateApp command, CommandContext context)
{ {
return CreateAsync(command, async x => return CreateAsync(command, async x =>
{ {
@ -47,10 +51,12 @@ namespace Squidex.Write.Apps
} }
x.Create(command); x.Create(command);
context.Succeed(command.AggregateId);
}); });
} }
public Task On(AssignContributor command) protected Task On(AssignContributor command, CommandContext context)
{ {
return UpdateAsync(command, async x => return UpdateAsync(command, async x =>
{ {
@ -65,29 +71,36 @@ namespace Squidex.Write.Apps
}); });
} }
public Task On(RemoveContributor command) protected Task On(AttachClient command, CommandContext context)
{ {
return UpdateAsync(command, x => x.RemoveContributor(command)); return UpdateAsync(command, x =>
{
var clientKey = keyGenerator.GenerateKey();
x.AttachClient(command, clientKey);
context.Succeed(x.Clients[command.ClientName]);
});
} }
public Task On(CreateClientKey command) protected Task On(RemoveContributor command, CommandContext context)
{ {
return UpdateAsync(command, x => x.CreateClientKey(command)); return UpdateAsync(command, x => x.RemoveContributor(command));
} }
public Task On(RevokeClientKey command) protected Task On(RevokeClient command, CommandContext context)
{ {
return UpdateAsync(command, x => x.RevokeClientKey(command)); return UpdateAsync(command, x => x.RevokeClient(command));
} }
public Task On(ConfigureLanguages command) protected Task On(ConfigureLanguages command, CommandContext context)
{ {
return UpdateAsync(command, x => x.ConfigureLanguages(command)); return UpdateAsync(command, x => x.ConfigureLanguages(command));
} }
public override Task<bool> HandleAsync(CommandContext context) public override Task<bool> HandleAsync(CommandContext context)
{ {
return context.IsHandled ? Task.FromResult(false) : this.DispatchActionAsync(context.Command); return context.IsHandled ? Task.FromResult(false) : this.DispatchActionAsync(context.Command, context);
} }
} }
} }

109
src/Squidex.Write/Apps/AppDomainObject.cs

@ -25,7 +25,7 @@ namespace Squidex.Write.Apps
public sealed class AppDomainObject : DomainObject public sealed class AppDomainObject : DomainObject
{ {
private static readonly List<Language> DefaultLanguages = new List<Language> { Language.GetLanguage("en") }; private static readonly List<Language> DefaultLanguages = new List<Language> { Language.GetLanguage("en") };
private readonly HashSet<string> clientKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, AppClient> clients = new Dictionary<string, AppClient>(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, PermissionLevel> contributors = new Dictionary<string, PermissionLevel>(); private readonly Dictionary<string, PermissionLevel> contributors = new Dictionary<string, PermissionLevel>();
private string name; private string name;
@ -39,6 +39,11 @@ namespace Squidex.Write.Apps
get { return contributors; } get { return contributors; }
} }
public IReadOnlyDictionary<string, AppClient> Clients
{
get { return clients; }
}
public AppDomainObject(Guid id, int version) public AppDomainObject(Guid id, int version)
: base(id, version) : base(id, version)
{ {
@ -59,14 +64,14 @@ namespace Squidex.Write.Apps
contributors.Remove(@event.ContributorId); contributors.Remove(@event.ContributorId);
} }
public void On(AppClientKeyCreated @event) public void On(AppClientAttached @event)
{ {
clientKeys.Add(@event.ClientKey); clients.Add(@event.ClientName, new AppClient(@event.ClientName, @event.ClientSecret, @event.ExpiresUtc));
} }
public void On(AppClientKeyRevoked @event) public void On(AppClientRevoked @event)
{ {
clientKeys.Remove(@event.ClientKey); clients.Remove(@event.ClientName);
} }
protected override void DispatchEvent(Envelope<IEvent> @event) protected override void DispatchEvent(Envelope<IEvent> @event)
@ -76,9 +81,11 @@ namespace Squidex.Write.Apps
public AppDomainObject Create(CreateApp command) public AppDomainObject Create(CreateApp command)
{ {
Guard.Valid(command, nameof(command), () => "Cannot create app"); Func<string> message = () => "Cannot create app";
Guard.Valid(command, nameof(command), message);
VerifyNotCreated(); ThrowIfCreated();
RaiseEvent(SimpleMapper.Map(command, new AppCreated())); RaiseEvent(SimpleMapper.Map(command, new AppCreated()));
@ -90,59 +97,73 @@ namespace Squidex.Write.Apps
public AppDomainObject AssignContributor(AssignContributor command) public AppDomainObject AssignContributor(AssignContributor command)
{ {
Guard.Valid(command, nameof(command), () => "Cannot assign contributor"); Func<string> message = () => "Cannot assign contributor";
VerifyCreated(); Guard.Valid(command, nameof(command), message);
VerifyOwnership(c => c[command.ContributorId] = command.Permission);
ThrowIfNotCreated();
ThrowIfNoOwner(c => c[command.ContributorId] = command.Permission, message);
RaiseEvent(SimpleMapper.Map(command, new AppContributorAssigned())); RaiseEvent(SimpleMapper.Map(command, new AppContributorAssigned()));
return this; return this;
} }
public AppDomainObject RemoveContributor(RemoveContributor command) public AppDomainObject RevokeClient(RevokeClient command)
{ {
Guard.Valid(command, nameof(command), () => "Cannot remove contributor"); Func<string> message = () => "Cannot revoke client";
VerifyCreated(); Guard.Valid(command, nameof(command), () => "Cannot revoke client");
VerifyContributorFound(command);
VerifyOwnership(c => c.Remove(command.ContributorId));
RaiseEvent(SimpleMapper.Map(command, new AppContributorRemoved())); ThrowIfNotCreated();
ThrowIfClientNotFound(command, message);
RaiseEvent(SimpleMapper.Map(command, new AppClientRevoked()));
return this; return this;
} }
public AppDomainObject ConfigureLanguages(ConfigureLanguages command) public AppDomainObject AttachClient(AttachClient command, string secret)
{ {
Guard.Valid(command, nameof(command), () => "Cannot remove contributor"); Func<string> message = () => "Cannot attach client";
VerifyCreated(); Guard.Valid(command, nameof(command), () => "Cannot attach client");
RaiseEvent(SimpleMapper.Map(command, new AppLanguagesConfigured())); ThrowIfNotCreated();
ThrowIfClientFound(command, message);
var expire = command.Timestamp.AddYears(1);
RaiseEvent(SimpleMapper.Map(command, new AppClientAttached { ClientSecret = secret, ExpiresUtc = expire }));
return this; return this;
} }
public AppDomainObject RevokeClientKey(RevokeClientKey command) public AppDomainObject RemoveContributor(RemoveContributor command)
{ {
Guard.Valid(command, nameof(command), () => "Cannot revoke client key"); Func<string> message = () => "Cannot remove contributor";
Guard.Valid(command, nameof(command), () => "Cannot remove contributor");
VerifyCreated(); ThrowIfNotCreated();
VerifyClientKeyFound(command); ThrowIfContributorNotFound(command, message);
RaiseEvent(SimpleMapper.Map(command, new AppClientKeyRevoked())); ThrowIfNoOwner(c => c.Remove(command.ContributorId), message);
RaiseEvent(SimpleMapper.Map(command, new AppContributorRemoved()));
return this; return this;
} }
public AppDomainObject CreateClientKey(CreateClientKey command) public AppDomainObject ConfigureLanguages(ConfigureLanguages command)
{ {
Guard.Valid(command, nameof(command), () => "Cannot create client key"); Func<string> message = () => "Cannot configure languages";
Guard.Valid(command, nameof(command), message);
VerifyCreated(); ThrowIfNotCreated();
RaiseEvent(SimpleMapper.Map(command, new AppClientKeyCreated())); RaiseEvent(SimpleMapper.Map(command, new AppLanguagesConfigured()));
return this; return this;
} }
@ -157,7 +178,7 @@ namespace Squidex.Write.Apps
return new AppContributorAssigned { ContributorId = command.SubjectId, Permission = PermissionLevel.Owner }; return new AppContributorAssigned { ContributorId = command.SubjectId, Permission = PermissionLevel.Owner };
} }
private void VerifyCreated() private void ThrowIfNotCreated()
{ {
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
{ {
@ -165,7 +186,7 @@ namespace Squidex.Write.Apps
} }
} }
private void VerifyNotCreated() private void ThrowIfCreated()
{ {
if (!string.IsNullOrWhiteSpace(name)) if (!string.IsNullOrWhiteSpace(name))
{ {
@ -173,27 +194,37 @@ namespace Squidex.Write.Apps
} }
} }
private void VerifyClientKeyFound(RevokeClientKey command) private void ThrowIfClientFound(AttachClient command, Func<string> message)
{
if (clients.ContainsKey(command.ClientName))
{
var error = new ValidationError("Client name is alreay part of the app", "ClientName");
throw new ValidationException(message(), error);
}
}
private void ThrowIfClientNotFound(RevokeClient command, Func<string> message)
{ {
if (!clientKeys.Contains(command.ClientKey)) if (!clients.ContainsKey(command.ClientName))
{ {
var error = new ValidationError("Client key is not part of the app", "ClientKey"); var error = new ValidationError("Client is not part of the app", "ClientName");
throw new ValidationException("Cannot revoke client key", error); throw new ValidationException(message(), error);
} }
} }
private void VerifyContributorFound(RemoveContributor command) private void ThrowIfContributorNotFound(RemoveContributor command, Func<string> message)
{ {
if (!contributors.ContainsKey(command.ContributorId)) if (!contributors.ContainsKey(command.ContributorId))
{ {
var error = new ValidationError("Contributor is not part of the app", "ContributorId"); var error = new ValidationError("Contributor is not part of the app", "ContributorId");
throw new ValidationException("Cannot remove contributor", error); throw new ValidationException(message(), error);
} }
} }
private void VerifyOwnership(Action<Dictionary<string, PermissionLevel>> change) private void ThrowIfNoOwner(Action<Dictionary<string, PermissionLevel>> change, Func<string> message)
{ {
var contributorsCopy = new Dictionary<string, PermissionLevel>(contributors); var contributorsCopy = new Dictionary<string, PermissionLevel>(contributors);
@ -203,7 +234,7 @@ namespace Squidex.Write.Apps
{ {
var error = new ValidationError("Contributor is the last owner", "ContributorId"); var error = new ValidationError("Contributor is the last owner", "ContributorId");
throw new ValidationException("Cannot assign contributor", error); throw new ValidationException(message(), error);
} }
} }
} }

10
src/Squidex.Write/Apps/Commands/CreateClientKey.cs → src/Squidex.Write/Apps/Commands/AttachClient.cs

@ -1,5 +1,5 @@
// ========================================================================== // ==========================================================================
// CreateClientKey.cs // AttachClient.cs
// Squidex Headless CMS // Squidex Headless CMS
// ========================================================================== // ==========================================================================
// Copyright (c) Squidex Group // Copyright (c) Squidex Group
@ -13,17 +13,17 @@ using Squidex.Infrastructure.CQRS.Commands;
namespace Squidex.Write.Apps.Commands namespace Squidex.Write.Apps.Commands
{ {
public sealed class CreateClientKey : AppAggregateCommand, ITimestampCommand, IValidatable public sealed class AttachClient : AppAggregateCommand, ITimestampCommand, IValidatable
{ {
public string ClientKey { get; set; } public string ClientName { get; set; }
public DateTime Timestamp { get; set; } public DateTime Timestamp { get; set; }
public void Validate(IList<ValidationError> errors) public void Validate(IList<ValidationError> errors)
{ {
if (string.IsNullOrWhiteSpace(ClientKey)) if (!ClientName.IsSlug())
{ {
errors.Add(new ValidationError("Client key is not assigned", nameof(ClientKey))); errors.Add(new ValidationError("Name must be a valid slug", nameof(ClientName)));
} }
} }
} }

8
src/Squidex.Write/Apps/Commands/CreateApp.cs

@ -6,6 +6,7 @@
// All rights reserved. // All rights reserved.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.Generic; using System.Collections.Generic;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.CQRS.Commands; using Squidex.Infrastructure.CQRS.Commands;
@ -18,11 +19,16 @@ namespace Squidex.Write.Apps.Commands
public string SubjectId { get; set; } public string SubjectId { get; set; }
public CreateApp()
{
AggregateId = Guid.NewGuid();
}
public void Validate(IList<ValidationError> errors) public void Validate(IList<ValidationError> errors)
{ {
if (!Name.IsSlug()) if (!Name.IsSlug())
{ {
errors.Add(new ValidationError("DisplayName must be a valid slug", nameof(Name))); errors.Add(new ValidationError("Name must be a valid slug", nameof(Name)));
} }
} }
} }

10
src/Squidex.Write/Apps/Commands/RevokeClientKey.cs → src/Squidex.Write/Apps/Commands/RevokeClient.cs

@ -1,5 +1,5 @@
// ========================================================================== // ==========================================================================
// RevokeClientKey.cs // RevokeClient.cs
// Squidex Headless CMS // Squidex Headless CMS
// ========================================================================== // ==========================================================================
// Copyright (c) Squidex Group // Copyright (c) Squidex Group
@ -11,15 +11,15 @@ using Squidex.Infrastructure;
namespace Squidex.Write.Apps.Commands namespace Squidex.Write.Apps.Commands
{ {
public class RevokeClientKey : AppAggregateCommand, IValidatable public class RevokeClient : AppAggregateCommand, IValidatable
{ {
public string ClientKey { get; set; } public string ClientName { get; set; }
public void Validate(IList<ValidationError> errors) public void Validate(IList<ValidationError> errors)
{ {
if (string.IsNullOrWhiteSpace(ClientKey)) if (!ClientName.IsSlug())
{ {
errors.Add(new ValidationError("Client key is not assigned", nameof(ClientKey))); errors.Add(new ValidationError("Name must be a valid slug", nameof(ClientName)));
} }
} }
} }

2
src/Squidex/Configurations/Identity/LazyClientStore.cs

@ -69,7 +69,7 @@ namespace Squidex.Configurations.Identity
{ {
ClientId = id, ClientId = id,
ClientName = id, ClientName = id,
ClientSecrets = app.ClientKeys.Select(x => new Secret(x.ClientKey, x.ExpiresUtc)).ToList(), ClientSecrets = app.Clients.Select(x => new Secret(x.ClientName, x.ExpiresUtc)).ToList(),
AccessTokenLifetime = (int)TimeSpan.FromDays(30).TotalSeconds, AccessTokenLifetime = (int)TimeSpan.FromDays(30).TotalSeconds,
AllowedGrantTypes = GrantTypes.ClientCredentials, AllowedGrantTypes = GrantTypes.ClientCredentials,
AllowedScopes = new List<string> AllowedScopes = new List<string>

17
src/Squidex/Configurations/Swagger/SwaggerUsage.cs

@ -21,12 +21,27 @@ namespace Squidex.Configurations.Swagger
{ {
public static class SwaggerUsage public static class SwaggerUsage
{ {
/*
class TypeNameGenerator : DefaultTypeNameGenerator
{
public override string Generate(JsonSchema4 schema, string typeNameHint)
{
try
{
return TypeNameRegistry.GetName(schema.Ty);
}
catch
{
return base.Generate(schema, typeNameHint);
}
}
}*/
public static void UseMySwagger(this IApplicationBuilder app) public static void UseMySwagger(this IApplicationBuilder app)
{ {
var options = app.ApplicationServices.GetService<IOptions<MyUrlsOptions>>().Value; var options = app.ApplicationServices.GetService<IOptions<MyUrlsOptions>>().Value;
var settings = var settings =
new SwaggerOwinSettings { Title = "Squidex API Specification" } new SwaggerOwinSettings { Title = "Squidex API Specification", IsAspNetCore = false}
.ConfigurePaths() .ConfigurePaths()
.ConfigureSchemaSettings() .ConfigureSchemaSettings()
.ConfigureIdentity(options); .ConfigureIdentity(options);

53
src/Squidex/Configurations/Swagger/XmlResponseTypesProcessor.cs

@ -6,11 +6,15 @@
// All rights reserved. // All rights reserved.
// ========================================================================== // ==========================================================================
using System;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using NJsonSchema;
using NJsonSchema.Infrastructure; using NJsonSchema.Infrastructure;
using NSwag; using NSwag;
using NSwag.CodeGeneration.SwaggerGenerators.WebApi.Processors; using NSwag.CodeGeneration.SwaggerGenerators.WebApi.Processors;
using NSwag.CodeGeneration.SwaggerGenerators.WebApi.Processors.Contexts; using NSwag.CodeGeneration.SwaggerGenerators.WebApi.Processors.Contexts;
using Squidex.Modules.Api;
// ReSharper disable UseObjectOrCollectionInitializer
namespace Squidex.Configurations.Swagger namespace Squidex.Configurations.Swagger
{ {
@ -20,6 +24,10 @@ namespace Squidex.Configurations.Swagger
public bool Process(OperationProcessorContext context) public bool Process(OperationProcessorContext context)
{ {
var hasOkResponse = false;
var operation = context.OperationDescription.Operation;
var returnsDescription = context.MethodInfo.GetXmlDocumentation("returns") ?? string.Empty; var returnsDescription = context.MethodInfo.GetXmlDocumentation("returns") ?? string.Empty;
foreach (Match match in ResponseRegex.Matches(returnsDescription)) foreach (Match match in ResponseRegex.Matches(returnsDescription))
@ -28,17 +36,58 @@ namespace Squidex.Configurations.Swagger
SwaggerResponse response; SwaggerResponse response;
if (!context.OperationDescription.Operation.Responses.TryGetValue(statusCode, out response)) if (!operation.Responses.TryGetValue(statusCode, out response))
{ {
response = new SwaggerResponse(); response = new SwaggerResponse();
context.OperationDescription.Operation.Responses[statusCode] = response; operation.Responses[statusCode] = response;
} }
response.Description = match.Groups["Description"].Value; response.Description = match.Groups["Description"].Value;
if (statusCode == "200")
{
hasOkResponse = true;
}
}
AddInternalErrorResponse(context, operation);
if (!hasOkResponse)
{
RemoveOkResponse(operation);
} }
return true; return true;
} }
private static void AddInternalErrorResponse(OperationProcessorContext context, SwaggerOperation operation)
{
if (operation.Responses.ContainsKey("500"))
{
return;
}
var errorType = typeof(ErrorDto);
var errorSchema = JsonObjectTypeDescription.FromType(errorType, new Attribute[0], EnumHandling.String);
var response = new SwaggerResponse { Description = "Operation failed." };
response.Schema = context.SwaggerGenerator.GenerateAndAppendSchemaFromType(errorType, errorSchema.IsNullable, null);
operation.Responses.Add("500", response);
}
private static void RemoveOkResponse(SwaggerOperation operation)
{
SwaggerResponse response;
if (operation.Responses.TryGetValue("200", out response) &&
response.Description != null &&
response.Description.Contains("=>"))
{
operation.Responses.Remove("200");
}
}
} }
} }

54
src/Squidex/Modules/Api/Apps/AppClientKeysController.cs → src/Squidex/Modules/Api/Apps/AppClientsController.cs

@ -28,32 +28,31 @@ namespace Squidex.Modules.Api.Apps
[ApiExceptionFilter] [ApiExceptionFilter]
[ServiceFilter(typeof(AppFilterAttribute))] [ServiceFilter(typeof(AppFilterAttribute))]
[SwaggerTag("Apps")] [SwaggerTag("Apps")]
public class AppClientKeysController : ControllerBase public class AppClientsController : ControllerBase
{ {
private readonly IAppProvider appProvider; private readonly IAppProvider appProvider;
private readonly ClientKeyGenerator keyGenerator;
public AppClientKeysController(ICommandBus commandBus, IAppProvider appProvider, ClientKeyGenerator keyGenerator) public AppClientsController(ICommandBus commandBus, IAppProvider appProvider)
: base(commandBus) : base(commandBus)
{ {
this.appProvider = appProvider; this.appProvider = appProvider;
this.keyGenerator = keyGenerator;
} }
/// <summary> /// <summary>
/// Get app client keys. /// Get app clients.
/// </summary> /// </summary>
/// <param name="app">The name of the app.</param> /// <param name="app">The name of the app.</param>
/// <returns> /// <returns>
/// 200 => Client keys returned. /// 200 => Client keys returned.
/// 404 => App not found.
/// </returns> /// </returns>
/// <remarks> /// <remarks>
/// Gets all configured client keys for the app with the specified name. /// Gets all configured client keys for the app with the specified name.
/// </remarks> /// </remarks>
[HttpGet] [HttpGet]
[Route("apps/{app}/client-keys/")] [Route("apps/{app}/clients/")]
[ProducesResponseType(typeof(ClientKeyDto[]), 200)] [ProducesResponseType(typeof(ClientDto[]), 200)]
public async Task<IActionResult> GetContributors(string app) public async Task<IActionResult> GetClients(string app)
{ {
var entity = await appProvider.FindAppByNameAsync(app); var entity = await appProvider.FindAppByNameAsync(app);
@ -62,33 +61,52 @@ namespace Squidex.Modules.Api.Apps
return NotFound(); return NotFound();
} }
var model = entity.ClientKeys.Select(x => SimpleMapper.Map(x, new ClientKeyDto())).ToList(); var response = entity.Clients.Select(x => SimpleMapper.Map(x, new ClientDto())).ToList();
return Ok(model); return Ok(response);
} }
/// <summary> /// <summary>
/// Create new client key. /// Create a new app client.
/// </summary> /// </summary>
/// <param name="app">The name of the app.</param> /// <param name="app">The name of the app.</param>
/// <param name="request">Client object that needs to be added to the app.</param>
/// <returns> /// <returns>
/// 201 => Client key generated. /// 201 => Client key generated.
/// 404 => App not found.
/// </returns> /// </returns>
/// <remarks> /// <remarks>
/// Create a new client key for the app with the specified name. /// Create a new client key for the app with the specified name.
/// The client key is auto generated on the server and returned. /// The client key is auto generated on the server and returned.
/// </remarks> /// </remarks>
[HttpPost] [HttpPost]
[Route("apps/{app}/client-keys/")] [Route("apps/{app}/clients/")]
[SwaggerTags("Apps")] [ProducesResponseType(typeof(ClientDto[]), 201)]
[ProducesResponseType(typeof(ClientKeyCreatedDto[]), 201)] public async Task<IActionResult> PostClient(string app, [FromBody] AttachClientDto request)
public async Task<IActionResult> PostClientKey(string app)
{ {
var clientKey = keyGenerator.GenerateKey(); var context = await CommandBus.PublishAsync(SimpleMapper.Map(request, new AttachClient()));
var result = context.Result<AppClient>();
await CommandBus.PublishAsync(new CreateClientKey { ClientKey = clientKey }); var response = SimpleMapper.Map(result, new ClientDto());
return StatusCode(201, new ClientKeyCreatedDto { ClientKey = clientKey }); return StatusCode(201, response);
}
/// <summary>
/// Revoke an app client
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="client">Client object that needs to be added to the app.</param>
/// <returns>
/// 204 => Client revoked.
/// </returns>
[HttpDelete]
[Route("apps/{app}/clients/{client}/")]
public async Task<IActionResult> DeleteClient(string app, string client)
{
await CommandBus.PublishAsync(new RevokeClient { ClientName = client });
return NoContent();
} }
} }
} }

28
src/Squidex/Modules/Api/Apps/AppContributorsController.cs

@ -38,11 +38,12 @@ namespace Squidex.Modules.Api.Apps
} }
/// <summary> /// <summary>
/// Get contributors for the app. /// Get app contributors.
/// </summary> /// </summary>
/// <param name="app">The name of the app.</param> /// <param name="app">The name of the app.</param>
/// <returns> /// <returns>
/// 200 => App contributors returned. /// 200 => App contributors returned.
/// 404 => App not found.
/// </returns> /// </returns>
[HttpGet] [HttpGet]
[Route("apps/{app}/contributors/")] [Route("apps/{app}/contributors/")]
@ -56,19 +57,20 @@ namespace Squidex.Modules.Api.Apps
return NotFound(); return NotFound();
} }
var model = entity.Contributors.Select(x => SimpleMapper.Map(x, new ContributorDto())).ToList(); var response = entity.Contributors.Select(x => SimpleMapper.Map(x, new ContributorDto())).ToList();
return Ok(model); return Ok(response);
} }
/// <summary> /// <summary>
/// Assign contributor to the app. /// Assign contributor to app.
/// </summary> /// </summary>
/// <param name="app">The name of the app.</param> /// <param name="app">The name of the app.</param>
/// <param name="model">Contributor object that needs to be added to the app.</param> /// <param name="model">Contributor object that needs to be added to the app.</param>
/// <returns> /// <returns>
/// 200 => User assigned to app. /// 204 => User assigned to app.
/// 400 => User is already assigned to the app or not found. /// 400 => User is already assigned to the app or not found.
/// 404 => App not found.
/// </returns> /// </returns>
[HttpPost] [HttpPost]
[Route("apps/{app}/contributors/")] [Route("apps/{app}/contributors/")]
@ -77,26 +79,26 @@ namespace Squidex.Modules.Api.Apps
{ {
await CommandBus.PublishAsync(SimpleMapper.Map(model, new AssignContributor())); await CommandBus.PublishAsync(SimpleMapper.Map(model, new AssignContributor()));
return Ok(); return NoContent();
} }
/// <summary> /// <summary>
/// Removes contributor from app. /// Remove contributor from app.
/// </summary> /// </summary>
/// <param name="app">The name of the app.</param> /// <param name="app">The name of the app.</param>
/// <param name="contributorId"></param> /// <param name="id">The id of the contributor.</param>
/// <returns> /// <returns>
/// 200 => User removed from app. /// 204 => User removed from app.
/// 400 => User is not assigned to the app. /// 400 => User is not assigned to the app.
/// </returns> /// </returns>
[HttpDelete] [HttpDelete]
[Route("apps/{app}/contributors/{contributorId}/")] [Route("apps/{app}/contributors/{id}/")]
[ProducesResponseType(typeof(ErrorDto[]), 400)] [ProducesResponseType(typeof(ErrorDto[]), 400)]
public async Task<IActionResult> DeleteContributor(string app, string contributorId) public async Task<IActionResult> DeleteContributor(string app, string id)
{ {
await CommandBus.PublishAsync(new RemoveContributor { ContributorId = contributorId }); await CommandBus.PublishAsync(new RemoveContributor { ContributorId = id });
return Ok(); return NoContent();
} }
} }
} }

15
src/Squidex/Modules/Api/Apps/AppController.cs

@ -39,7 +39,7 @@ namespace Squidex.Modules.Api.Apps
} }
/// <summary> /// <summary>
/// Gets your apps. /// Get your apps.
/// </summary> /// </summary>
/// <returns> /// <returns>
/// 200 => Apps returned. /// 200 => Apps returned.
@ -56,7 +56,7 @@ namespace Squidex.Modules.Api.Apps
var subject = HttpContext.User.OpenIdSubject(); var subject = HttpContext.User.OpenIdSubject();
var schemas = await appRepository.QueryAllAsync(subject); var schemas = await appRepository.QueryAllAsync(subject);
var models = schemas.Select(s => var response = schemas.Select(s =>
{ {
var dto = SimpleMapper.Map(s, new AppDto()); var dto = SimpleMapper.Map(s, new AppDto());
@ -65,13 +65,13 @@ namespace Squidex.Modules.Api.Apps
return dto; return dto;
}).ToList(); }).ToList();
return Ok(models); return Ok(response);
} }
/// <summary> /// <summary>
/// Create a new app. /// Create a new app.
/// </summary> /// </summary>
/// <param name="model">The app object that needs to be added to squided.</param> /// <param name="model">The app object that needs to be added to squidex.</param>
/// <returns> /// <returns>
/// 201 => App created. /// 201 => App created.
/// 400 => App object is not valid. /// 400 => App object is not valid.
@ -88,11 +88,12 @@ namespace Squidex.Modules.Api.Apps
[ProducesResponseType(typeof(ErrorDto), 409)] [ProducesResponseType(typeof(ErrorDto), 409)]
public async Task<IActionResult> PostApp([FromBody] CreateAppDto model) public async Task<IActionResult> PostApp([FromBody] CreateAppDto model)
{ {
var command = SimpleMapper.Map(model, new CreateApp { AggregateId = Guid.NewGuid() }); var command = SimpleMapper.Map(model, new CreateApp());
await CommandBus.PublishAsync(command); var context = await CommandBus.PublishAsync(command);
var result = context.Result<Guid>();
return CreatedAtAction(nameof(GetApps), new EntityCreatedDto { Id = command.AggregateId.ToString() }); return CreatedAtAction(nameof(GetApps), new EntityCreatedDto { Id = result.ToString() });
} }
} }
} }

8
src/Squidex/Modules/Api/Apps/AppLanguagesController.cs

@ -43,6 +43,7 @@ namespace Squidex.Modules.Api.Apps
/// <param name="app">The name of the app.</param> /// <param name="app">The name of the app.</param>
/// <returns> /// <returns>
/// 200 => Language configuration returned. /// 200 => Language configuration returned.
/// 404 => App not found.
/// </returns> /// </returns>
[HttpGet] [HttpGet]
[Route("apps/{app}/languages/")] [Route("apps/{app}/languages/")]
@ -62,13 +63,14 @@ namespace Squidex.Modules.Api.Apps
} }
/// <summary> /// <summary>
/// Configures the app languages. /// Configure the app languages.
/// </summary> /// </summary>
/// <param name="app">The name of the app.</param> /// <param name="app">The name of the app.</param>
/// <param name="model">The language configuration for the app.</param> /// <param name="model">The language configuration for the app.</param>
/// <returns> /// <returns>
/// 201 => App languages configured. /// 204 => App languages configured.
/// 400 => Language configuration is empty or contains an invalid language. /// 400 => Language configuration is empty or contains an invalid language.
/// 404 => App not found.
/// </returns> /// </returns>
/// <remarks> /// <remarks>
/// The ordering of the languages matterns: When you retrieve a content with a localized content squidex tries /// The ordering of the languages matterns: When you retrieve a content with a localized content squidex tries
@ -82,7 +84,7 @@ namespace Squidex.Modules.Api.Apps
{ {
await CommandBus.PublishAsync(SimpleMapper.Map(model, new ConfigureLanguages())); await CommandBus.PublishAsync(SimpleMapper.Map(model, new ConfigureLanguages()));
return Ok(); return NoContent();
} }
} }
} }

9
src/Squidex/Modules/Api/Apps/Models/ClientKeyCreatedDto.cs → src/Squidex/Modules/Api/Apps/Models/AttachClientDto.cs

@ -1,5 +1,5 @@
// ========================================================================== // ==========================================================================
// ClientKeyCreatedDto.cs // AttachClientDto.cs
// Squidex Headless CMS // Squidex Headless CMS
// ========================================================================== // ==========================================================================
// Copyright (c) Squidex Group // Copyright (c) Squidex Group
@ -10,12 +10,13 @@ using System.ComponentModel.DataAnnotations;
namespace Squidex.Modules.Api.Apps.Models namespace Squidex.Modules.Api.Apps.Models
{ {
public sealed class ClientKeyCreatedDto public class AttachClientDto
{ {
/// <summary> /// <summary>
/// The created client key. /// The name of the client.
/// </summary> /// </summary>
[Required] [Required]
public string ClientKey { get; set; } [RegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")]
public string ClientName { get; set; }
} }
} }

12
src/Squidex/Modules/Api/Apps/Models/ClientKeyDto.cs → src/Squidex/Modules/Api/Apps/Models/ClientDto.cs

@ -11,13 +11,19 @@ using System.ComponentModel.DataAnnotations;
namespace Squidex.Modules.Api.Apps.Models namespace Squidex.Modules.Api.Apps.Models
{ {
public sealed class ClientKeyDto public sealed class ClientDto
{ {
/// <summary> /// <summary>
/// The client key. /// The client name.
/// </summary> /// </summary>
[Required] [Required]
public string ClientKey { get; set; } public string ClientName { get; set; }
/// <summary>
/// The client secret.
/// </summary>
[Required]
public string ClientSecret { get; set; }
/// <summary> /// <summary>
/// The date and time when the client key expires. /// The date and time when the client key expires.

2
src/Squidex/Modules/Api/Apps/Models/CreateAppDto.cs

@ -13,7 +13,7 @@ namespace Squidex.Modules.Api.Apps.Models
public sealed class CreateAppDto public sealed class CreateAppDto
{ {
/// <summary> /// <summary>
/// The new name of the app. /// The name of the app.
/// </summary> /// </summary>
[Required] [Required]
[RegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")] [RegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")]

4
src/Squidex/Modules/Api/Languages/LanguagesController.cs

@ -38,9 +38,9 @@ namespace Squidex.Modules.Api.Languages
[ProducesResponseType(typeof(string[]), 200)] [ProducesResponseType(typeof(string[]), 200)]
public IActionResult GetLanguages() public IActionResult GetLanguages()
{ {
var model = Language.AllLanguages.Select(x => SimpleMapper.Map(x, new LanguageDto())).ToList(); var response = Language.AllLanguages.Select(x => SimpleMapper.Map(x, new LanguageDto())).ToList();
return Ok(model); return Ok(response);
} }
} }
} }

23
src/Squidex/Modules/Api/Schemas/Models/CreateFieldDto.cs

@ -1,23 +0,0 @@
// ==========================================================================
// CreateFieldDto.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Squidex.Modules.Api.Schemas.Models
{
public class CreateFieldDto
{
[JsonProperty("$type")]
public string Type { get; set; }
public string Name { get; set; }
public JObject Properties { get; set; }
}
}

9
src/Squidex/Modules/Api/Schemas/Models/CreateSchemaDto.cs

@ -6,14 +6,17 @@
// All rights reserved. // All rights reserved.
// ========================================================================== // ==========================================================================
using Squidex.Core.Schemas; using System.ComponentModel.DataAnnotations;
namespace Squidex.Modules.Api.Schemas.Models namespace Squidex.Modules.Api.Schemas.Models
{ {
public class CreateSchemaDto public class CreateSchemaDto
{ {
/// <summary>
/// The name of the schema.
/// </summary>
[Required]
[RegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")]
public string Name { get; set; } public string Name { get; set; }
public FieldProperties Properties { get; set; }
} }
} }

52
src/Squidex/Modules/Api/Schemas/Models/FieldDto.cs

@ -0,0 +1,52 @@
// ==========================================================================
// FieldDto.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using System.Runtime.Serialization;
using Newtonsoft.Json;
using NJsonSchema.Converters;
using Squidex.Modules.Api.Schemas.Models.Fields;
namespace Squidex.Modules.Api.Schemas.Models
{
[JsonConverter(typeof(JsonInheritanceConverter), "fieldType")]
[KnownType(typeof(NumberField))]
[KnownType(typeof(StringField))]
public class FieldDto
{
/// <summary>
/// The name of the field. Must be unique within the schema.
/// </summary>
[Required]
[RegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")]
public string Name { get; set; }
/// <summary>
/// Optional label for the editor.
/// </summary>
[StringLength(100)]
public string Label { get; set; }
/// <summary>
/// Hints to describe the schema.
/// </summary>
[StringLength(1000)]
public string Hints { get; set; }
/// <summary>
/// Placeholder to show when no value has been entered.
/// </summary>
[StringLength(100)]
public string Placeholder { get; set; }
/// <summary>
/// Indicates if the field is required.
/// </summary>
public bool IsRequired { get; set; }
}
}

33
src/Squidex/Modules/Api/Schemas/Models/Fields/NumberField.cs

@ -0,0 +1,33 @@
// ==========================================================================
// NumberField.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
namespace Squidex.Modules.Api.Schemas.Models.Fields
{
public class NumberField : FieldDto
{
/// <summary>
/// The default value for the field value.
/// </summary>
public double? DefaultValue { get; set; }
/// <summary>
/// The maximum allowed value for the field value.
/// </summary>
public double? MaxValue { get; set; }
/// <summary>
/// The minimum allowed value for the field value.
/// </summary>
public double? MinValue { get; set; }
/// <summary>
/// The allowed values for the field value.
/// </summary>
public double[] AllowedValues { get; set; }
}
}

38
src/Squidex/Modules/Api/Schemas/Models/Fields/StringField.cs

@ -0,0 +1,38 @@
// ==========================================================================
// StringField.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
namespace Squidex.Modules.Api.Schemas.Models.Fields
{
public sealed class StringField : FieldDto
{
/// <summary>
/// The default value for the field value.
/// </summary>
public string DefaultValue { get; set; }
/// <summary>
/// The pattern to enforce a specific format for the field value.
/// </summary>
public string Pattern { get; set; }
/// <summary>
/// The minimum allowed length for the field value.
/// </summary>
public int? MinLength { get; set; }
/// <summary>
/// The maximum allowed length for the field value.
/// </summary>
public int? MaxLength { get; set; }
/// <summary>
/// The allowed values for the field value.
/// </summary>
public double[] AllowedValues { get; set; }
}
}

57
src/Squidex/Modules/Api/Schemas/Models/SchemaDetailsDto.cs

@ -0,0 +1,57 @@
// ==========================================================================
// SchemaDetailsDto.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace Squidex.Modules.Api.Schemas.Models
{
public sealed class SchemaDetailsDto
{
/// <summary>
/// The id of the schema.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// The name of the schema. Unique within the app.
/// </summary>
[Required]
[RegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")]
public string Name { get; set; }
/// <summary>
/// The list of fields.
/// </summary>
[Required]
public List<FieldDto> Fields { get; set; }
/// <summary>
/// Optional label for the editor.
/// </summary>
[StringLength(100)]
public string Label { get; set; }
/// <summary>
/// Hints to describe the schema.
/// </summary>
[StringLength(1000)]
public string Hints { get; set; }
/// <summary>
/// The date and time when the schema has been creaed.
/// </summary>
public DateTime Created { get; set; }
/// <summary>
/// The date and time when the schema has been modified last.
/// </summary>
public DateTime LastModified { get; set; }
}
}

17
src/Squidex/Modules/Api/Schemas/Models/ListSchemaDto.cs → src/Squidex/Modules/Api/Schemas/Models/SchemaDto.cs

@ -7,17 +7,32 @@
// ========================================================================== // ==========================================================================
using System; using System;
using System.ComponentModel.DataAnnotations;
namespace Squidex.Modules.Api.Schemas.Models namespace Squidex.Modules.Api.Schemas.Models
{ {
public class ListSchemaDto public class SchemaDto
{ {
/// <summary>
/// The id of the schema.
/// </summary>
public Guid Id { get; set; } public Guid Id { get; set; }
/// <summary>
/// The name of the schema. Unique within the app.
/// </summary>
[Required]
[RegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")]
public string Name { get; set; } public string Name { get; set; }
/// <summary>
/// The date and time when the schema has been creaed.
/// </summary>
public DateTime Created { get; set; } public DateTime Created { get; set; }
/// <summary>
/// The date and time when the schema has been modified last.
/// </summary>
public DateTime LastModified { get; set; } public DateTime LastModified { get; set; }
} }
} }

17
src/Squidex/Modules/Api/Schemas/Models/UpdateFieldDto.cs

@ -1,17 +0,0 @@
// ==========================================================================
// UpdateFieldDto.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
using Newtonsoft.Json.Linq;
namespace Squidex.Modules.Api.Schemas.Models
{
public class UpdateFieldDto
{
public JObject Properties { get; set; }
}
}

14
src/Squidex/Modules/Api/Schemas/Models/UpdateSchemaDto.cs

@ -6,12 +6,22 @@
// All rights reserved. // All rights reserved.
// ========================================================================== // ==========================================================================
using Squidex.Core.Schemas; using System.ComponentModel.DataAnnotations;
namespace Squidex.Modules.Api.Schemas.Models namespace Squidex.Modules.Api.Schemas.Models
{ {
public class UpdateSchemaDto public class UpdateSchemaDto
{ {
public SchemaProperties Properties { get; set; } /// <summary>
/// Optional label for the editor.
/// </summary>
[StringLength(100)]
public string Label { get; set; }
/// <summary>
/// Hints to describe the schema.
/// </summary>
[StringLength(1000)]
public string Hints { get; set; }
} }
} }

177
src/Squidex/Modules/Api/Schemas/SchemaFieldsController.cs

@ -18,78 +18,197 @@ using Squidex.Write.Schemas.Commands;
namespace Squidex.Modules.Api.Schemas namespace Squidex.Modules.Api.Schemas
{ {
[Authorize] /// <summary>
/// Manages and retrieves information about schemas.
/// </summary>
[Authorize("app-owner,app-developer")]
[ApiExceptionFilter] [ApiExceptionFilter]
[ServiceFilter(typeof(AppFilterAttribute))] [ServiceFilter(typeof(AppFilterAttribute))]
[SwaggerIgnore] [SwaggerTag("Schemas")]
public class SchemasFieldsController : ControllerBase public class SchemaFieldsController : ControllerBase
{ {
public SchemasFieldsController(ICommandBus commandBus) public SchemaFieldsController(ICommandBus commandBus)
: base(commandBus) : base(commandBus)
{ {
} }
/// <summary>
/// Create a new schema field.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="name">The name of the schema.</param>
/// <param name="model">The field object that needs to be added to the schema.</param>
/// <returns>
/// 201 => Field created.
/// 409 => Field name already in use.
/// 404 => App or schema not found.
/// 404 => Field properties not valid.
/// </returns>
[HttpPost] [HttpPost]
[Route("apps/{app}/schemas/{name}/fields/")] [Route("apps/{app}/schemas/{name}/fields/")]
public Task Add(string name, [FromBody] CreateFieldDto model) [ProducesResponseType(typeof(EntityCreatedDto), 201)]
[ProducesResponseType(typeof(ErrorDto), 409)]
[ProducesResponseType(typeof(ErrorDto), 400)]
public async Task<IActionResult> PostField(string app, string name, [FromBody] FieldDto model)
{ {
var command = SimpleMapper.Map(model, new AddField()); var command = SimpleMapper.Map(model, new AddField());
return CommandBus.PublishAsync(command); var context = await CommandBus.PublishAsync(command);
var result = context.Result<long>();
return StatusCode(201, new EntityCreatedDto { Id = result.ToString() });
} }
/// <summary>
/// Update a schema field.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="name">The name of the schema.</param>
/// <param name="model">The field object that needs to be added to the schema.</param>
/// <returns>
/// 204 => Field created.
/// 409 => Field name already in use.
/// 404 => App, schema or field not found.
/// 404 => Field properties not valid.
/// </returns>
[HttpPut] [HttpPut]
[Route("apps/{app}/schemas/{name}/fields/{fieldId:long}/")] [Route("apps/{app}/schemas/{name}/fields/{id:long}/")]
public Task Update(string name, long fieldId, [FromBody] UpdateFieldDto model) [ProducesResponseType(typeof(ErrorDto), 409)]
[ProducesResponseType(typeof(ErrorDto), 400)]
public async Task<IActionResult> PutField(string app, string name, long id, [FromBody] FieldDto model)
{ {
var command = SimpleMapper.Map(model, new UpdateField()); var command = SimpleMapper.Map(model, new UpdateField());
return CommandBus.PublishAsync(command); await CommandBus.PublishAsync(command);
return NoContent();
} }
/// <summary>
/// Hide a schema field.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="name">The name of the schema.</param>
/// <param name="id">The id of the field to hide.</param>
/// <returns>
/// 400 => Field already hidden.
/// 204 => Schema field hidden.
/// 404 => App, schema or field not found.
/// </returns>
/// <remarks>
/// A hidden field is not part of the API response, but can still be edited in the portal.
/// </remarks>
[HttpPut] [HttpPut]
[Route("apps/{app}/schemas/{name}/fields/{fieldId:long}/hide/")] [Route("apps/{app}/schemas/{name}/fields/{id:long}/hide/")]
public Task Hide(string name, long fieldId) [ProducesResponseType(typeof(ErrorDto), 400)]
public async Task<IActionResult> HideField(string app, string name, long id)
{ {
var command = new HideField { FieldId = fieldId }; var command = new HideField { FieldId = id };
return CommandBus.PublishAsync(command); await CommandBus.PublishAsync(command);
return NoContent();
} }
/// <summary>
/// Show a schema field.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="name">The name of the schema.</param>
/// <param name="id">The id of the field to shows.</param>
/// <returns>
/// 400 => Field already visible.
/// 204 => Schema field shown.
/// 404 => App, schema or field not found.
/// </returns>
/// <remarks>
/// A hidden field is not part of the API response, but can still be edited in the portal.
/// </remarks>
[HttpPut] [HttpPut]
[Route("apps/{app}/schemas/{name}/fields/{fieldId:long}/show/")] [Route("apps/{app}/schemas/{name}/fields/{id:long}/show/")]
public Task Show(string name, long fieldId) [ProducesResponseType(typeof(ErrorDto), 400)]
public async Task<IActionResult> ShowField(string app, string name, long id)
{ {
var command = new ShowField { FieldId = fieldId }; var command = new ShowField { FieldId = id };
await CommandBus.PublishAsync(command);
return CommandBus.PublishAsync(command); return NoContent();
} }
/// <summary>
/// Enable a schema field.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="name">The name of the schema.</param>
/// <param name="id">The id of the field to enable.</param>
/// <returns>
/// 400 => Field already enabled.
/// 204 => Schema field enabled.
/// 404 => App, schema or field not found.
/// </returns>
/// <remarks>
/// A disabled field cannot not be edited in the squidex portal anymore,
/// but will be part of the API response.
/// </remarks>
[HttpPut] [HttpPut]
[Route("schemas/{name}/fields/{fieldId:long}/enable/")] [Route("apps/{app}/schemas/{name}/fields/{id:long}/enable/")]
public Task Enable(string name, long fieldId) [ProducesResponseType(typeof(ErrorDto), 400)]
public async Task<IActionResult> EnableField(string app, string name, long id)
{ {
var command = new EnableField { FieldId = fieldId }; var command = new EnableField { FieldId = id };
return CommandBus.PublishAsync(command); await CommandBus.PublishAsync(command);
return NoContent();
} }
/// <summary>
/// Disable a schema field.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="name">The name of the schema.</param>
/// <param name="id">The id of the field to disable.</param>
/// <returns>
/// 400 => Field already disabled.
/// 204 => Schema field disabled.
/// 404 => App, schema or field not found.
/// </returns>
/// <remarks>
/// A disabled field cannot not be edited in the squidex portal anymore,
/// but will be part of the API response.
/// </remarks>
[HttpPut] [HttpPut]
[Route("apps/{app}/schemas/{name}/fields/{fieldId:long}/disable/")] [Route("apps/{app}/schemas/{name}/fields/{id:long}/disable/")]
public Task Disable(string name, long fieldId) [ProducesResponseType(typeof(ErrorDto), 400)]
public async Task<IActionResult> DisableField(string app, string name, long id)
{ {
var command = new DisableField { FieldId = fieldId }; var command = new DisableField { FieldId = id };
await CommandBus.PublishAsync(command);
return CommandBus.PublishAsync(command); return NoContent();
} }
/// <summary>
/// Delete a schema field.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="name">The name of the schema.</param>
/// <param name="id">The id of the field to disable.</param>
/// <returns>
/// 204 => Schema field deleted.
/// 404 => App, schema or field not found.
/// </returns>
[HttpDelete] [HttpDelete]
[Route("apps/{app}/schemas/{name}/fields/{fieldId:long}/")] [Route("apps/{app}/schemas/{name}/fields/{id:long}/")]
public Task Delete(string name, long fieldId) public async Task<IActionResult> DeleteField(string app, string name, long id)
{ {
var command = new DeleteField { FieldId = fieldId }; var command = new DeleteField { FieldId = id };
await CommandBus.PublishAsync(command);
return CommandBus.PublishAsync(command); return NoContent();
} }
} }
} }

75
src/Squidex/Modules/Api/Schemas/SchemasController.cs

@ -7,7 +7,6 @@
// ========================================================================== // ==========================================================================
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@ -18,15 +17,17 @@ using Squidex.Infrastructure.Reflection;
using Squidex.Modules.Api.Schemas.Models; using Squidex.Modules.Api.Schemas.Models;
using Squidex.Pipeline; using Squidex.Pipeline;
using Squidex.Read.Schemas.Repositories; using Squidex.Read.Schemas.Repositories;
using Squidex.Store.MongoDb.Schemas.Models;
using Squidex.Write.Schemas.Commands; using Squidex.Write.Schemas.Commands;
namespace Squidex.Modules.Api.Schemas namespace Squidex.Modules.Api.Schemas
{ {
[Authorize] /// <summary>
/// Manages and retrieves information about schemas.
/// </summary>
[Authorize("app-owner,app-developer")]
[ApiExceptionFilter] [ApiExceptionFilter]
[ServiceFilter(typeof(AppFilterAttribute))] [ServiceFilter(typeof(AppFilterAttribute))]
[SwaggerIgnore] [SwaggerTag("Schemas")]
public class SchemasController : ControllerBase public class SchemasController : ControllerBase
{ {
private readonly ISchemaRepository schemaRepository; private readonly ISchemaRepository schemaRepository;
@ -37,18 +38,36 @@ namespace Squidex.Modules.Api.Schemas
this.schemaRepository = schemaRepository; this.schemaRepository = schemaRepository;
} }
/// <summary>
/// Get app schemas.
/// </summary>
/// <returns>
/// 200 => Schemas returned.
/// </returns>
[HttpGet] [HttpGet]
[Route("apps/{app}/schemas/")] [Route("apps/{app}/schemas/")]
public async Task<List<ListSchemaDto>> Query() [ProducesResponseType(typeof(SchemaDto[]), 200)]
public async Task<IActionResult> GetSchemas()
{ {
var schemas = await schemaRepository.QueryAllAsync(AppId); var schemas = await schemaRepository.QueryAllAsync(AppId);
return schemas.Select(s => SimpleMapper.Map(s, new ListSchemaDto())).ToList(); var model = schemas.Select(s => SimpleMapper.Map(s, new SchemaDto())).ToList();
return Ok(model);
} }
/// <summary>
/// Get a schema by name.
/// </summary>
/// <param name="name">The name of the schema to retrieve.</param>
/// <returns>
/// 200 => Schema found.
/// 404 => Schema not found.
/// </returns>
[HttpGet] [HttpGet]
[Route("apps/{app}/schemas/{name}/")] [Route("apps/{app}/schemas/{name}/")]
public async Task<ActionResult> Get(string name) [ProducesResponseType(typeof(SchemaDetailsDto[]), 200)]
public async Task<IActionResult> GetSchema(string name)
{ {
var entity = await schemaRepository.FindSchemaAsync(AppId, name); var entity = await schemaRepository.FindSchemaAsync(AppId, name);
@ -57,25 +76,45 @@ namespace Squidex.Modules.Api.Schemas
return NotFound(); return NotFound();
} }
var model = SchemaDto.Create(entity.Schema); return Ok(null);
return Ok(model);
} }
/// <summary>
/// Create a new schema.
/// </summary>
/// <param name="model">The schema object that needs to be added to the app.</param>
/// <param name="app">The name of the app to create the schema to.</param>
/// <returns>
/// 201 => Schema created.
/// 400 => Schema name is not valid.
/// 409 => Schema name already in use.
/// </returns>
[HttpPost] [HttpPost]
[Route("apps/{app}/schemas/")] [Route("apps/{app}/schemas/")]
public async Task<ActionResult> Create([FromBody] CreateSchemaDto model) [ProducesResponseType(typeof(EntityCreatedDto), 201)]
[ProducesResponseType(typeof(ErrorDto), 400)]
[ProducesResponseType(typeof(ErrorDto), 409)]
public async Task<IActionResult> PostSchema(string app, [FromBody] CreateSchemaDto model)
{ {
var command = SimpleMapper.Map(model, new CreateSchema { AggregateId = Guid.NewGuid() }); var command = SimpleMapper.Map(model, new CreateSchema { AggregateId = Guid.NewGuid() });
await CommandBus.PublishAsync(command); await CommandBus.PublishAsync(command);
return CreatedAtAction("Get", new { name = model.Name }, new EntityCreatedDto { Id = command.Name }); return CreatedAtAction(nameof(GetSchema), new { name = model.Name }, new EntityCreatedDto { Id = command.Name });
} }
/// <summary>
/// Update a schema.
/// </summary>
/// <param name="app">The app where the schema is a part of.</param>
/// <param name="name">The name of the schema.</param>
/// <param name="model">The schema object that needs to updated.</param>
/// <returns>
/// 204 => Schema updated.
/// </returns>
[HttpPut] [HttpPut]
[Route("apps/{app}/schemas/{name}/")] [Route("apps/{app}/schemas/{name}/")]
public async Task<ActionResult> Update(string name, [FromBody] UpdateSchemaDto model) public async Task<IActionResult> PutSchema(string app, string name, [FromBody] UpdateSchemaDto model)
{ {
var command = SimpleMapper.Map(model, new UpdateSchema()); var command = SimpleMapper.Map(model, new UpdateSchema());
@ -84,9 +123,17 @@ namespace Squidex.Modules.Api.Schemas
return NoContent(); return NoContent();
} }
/// <summary>
/// Delete a schema.
/// </summary>
/// <param name="app">The app where the schema is a part of.</param>
/// <param name="name">The name of the schema to delete.</param>
/// <returns>
/// 204 => Schema has been deleted.
/// </returns>
[HttpDelete] [HttpDelete]
[Route("apps/{app}/schemas/{name}/")] [Route("apps/{app}/schemas/{name}/")]
public async Task<ActionResult> Delete(string name) public async Task<IActionResult> DeleteSchema(string app, string name)
{ {
await CommandBus.PublishAsync(new DeleteSchema()); await CommandBus.PublishAsync(new DeleteSchema());

2
src/Squidex/Modules/Api/Users/Models/UserDto.cs

@ -13,7 +13,7 @@ namespace Squidex.Modules.Api.Users.Models
public sealed class UserDto public sealed class UserDto
{ {
/// <summary> /// <summary>
/// The id of the user. Unique value. /// The id of the user.
/// </summary> /// </summary>
[Required] [Required]
public string Id { get; set; } public string Id { get; set; }

8
src/Squidex/Modules/Api/Users/UsersController.cs

@ -50,9 +50,9 @@ namespace Squidex.Modules.Api.Users
{ {
var entities = await userRepository.FindUsersByQuery(query ?? string.Empty); var entities = await userRepository.FindUsersByQuery(query ?? string.Empty);
var model = entities.Select(x => SimpleMapper.Map(x, new UserDto())).ToList(); var response = entities.Select(x => SimpleMapper.Map(x, new UserDto())).ToList();
return Ok(model); return Ok(response);
} }
/// <summary> /// <summary>
@ -75,9 +75,9 @@ namespace Squidex.Modules.Api.Users
return NotFound(); return NotFound();
} }
var model = SimpleMapper.Map(entity, new UserDto()); var response = SimpleMapper.Map(entity, new UserDto());
return Ok(model); return Ok(response);
} }
} }
} }

4
src/Squidex/app/app.module.ts

@ -14,7 +14,7 @@ import { DndModule } from 'ng2-dnd';
import { import {
ApiUrlConfig, ApiUrlConfig,
AppClientKeysService, AppClientsService,
AppContributorsService, AppContributorsService,
AppLanguagesService, AppLanguagesService,
AppMustExistGuard, AppMustExistGuard,
@ -62,7 +62,7 @@ const baseUrl = window.location.protocol + '//' + window.location.host + '/';
AppComponent AppComponent
], ],
providers: [ providers: [
AppClientKeysService, AppClientsService,
AppContributorsService, AppContributorsService,
AppLanguagesService, AppLanguagesService,
AppsStoreService, AppsStoreService,

6
src/Squidex/app/app.routes.ts

@ -11,8 +11,8 @@ import * as Ng2Router from '@angular/router';
import { import {
AppsPageComponent, AppsPageComponent,
AppAreaComponent, AppAreaComponent,
ClientsPageComponent,
ContributorsPageComponent, ContributorsPageComponent,
CredentialsPageComponent,
DashboardPageComponent, DashboardPageComponent,
InternalAreaComponent, InternalAreaComponent,
HomePageComponent, HomePageComponent,
@ -65,8 +65,8 @@ export const routes: Ng2Router.Routes = [
component: ContributorsPageComponent component: ContributorsPageComponent
}, },
{ {
path: 'credentials', path: 'clients',
component: CredentialsPageComponent component: ClientsPageComponent
}, },
{ {
path: 'languages', path: 'languages',

2
src/Squidex/app/components/internal/app/left-menu.component.html

@ -28,7 +28,7 @@
<a class="nav-link" routerLink="../contributors" routerLinkActive="active">Contributors</a> <a class="nav-link" routerLink="../contributors" routerLinkActive="active">Contributors</a>
</li> </li>
<li class="subnav-item"> <li class="subnav-item">
<a class="nav-link" routerLink="../credentials" routerLinkActive="active">Credentials</a> <a class="nav-link" routerLink="../clients" routerLinkActive="active">Clients</a>
</li> </li>
<li class="subnav-item"> <li class="subnav-item">
<a class="nav-link" routerLink="../languages" routerLinkActive="active">Languages</a> <a class="nav-link" routerLink="../languages" routerLinkActive="active">Languages</a>

91
src/Squidex/app/components/internal/app/settings/clients-page.component.html

@ -0,0 +1,91 @@
<div class="layout">
<div class="layout-left">
<sqx-left-menu></sqx-left-menu>
</div>
<div class="layout-middle">
<div class="layout-middle-header">
<h1>
<i class="layout-title-icon icon-settings"></i> Clients
</h1>
</div>
<div class="layout-middle-content">
<div class="card">
<div class="card-block">
<div class="clients-empty" *ngIf="appClients && appClients.length === 0">
No client created yet.
</div>
<table class="table table-borderless-top table-fixed clients-table">
<colgroup>
<col style="width: 100%" />
<col style="width: 160px" />
</colgroup>
<tr *ngFor="let client of appClients">
<td>
<table class="table table-middle table-sm table-borderless table-fixed client-info">
<colgroup>
<col style="width: 160px; text-align: right;" />
<col style="width: 100%" />
</colgroup>
<tr>
<td>Client Name:</td>
<td>
<input readonly class="form-control" [attr.value]="fullAppName(client)" />
</td>
</tr>
<tr>
<td>Client Secret:</td>
<td>
<input readonly class="form-control" [attr.value]="client.clientSecret" />
</td>
</tr>
<tr>
<td>Expires:</td>
<td class="client-expires">
{{client.expiresUtc}}
</td>
</tr>
</table>
</td>
<td>
<button class="btn btn-block btn-danger client-delete" (click)="revokeClient(client)">Revoke</button>
<button class="btn btn-block btn-default">Create Token</button>
</td>
</tr>
</table>
</div>
<div class="card-footer">
<div *ngIf="creationError" [@fade]>
<div class="form-error">
{{creationError}}
</div>
</div>
<form class="form-inline" [formGroup]="createForm" (submit)="attachClient()">
<div class="errors-box" *ngIf="createForm.get('name').invalid && createForm.get('name').dirty">
<div class="errors">
<span *ngIf="createForm.get('name').hasError('required')">
Name is required.
</span>
<span *ngIf="createForm.get('name').hasError('maxlength')">
Name can not have more than 40 characters.
</span>
<span *ngIf="createForm.get('name').hasError('pattern')">
Name can contain lower case letters (a-z), numbers and dashes (not at the end).
</span>
</div>
</div>
<div class="form-group">
<input type="text" class="form-control" id="app-name" formControlName="name" placeholder="Enter client name" />
</div>
<button type="submit" class="btn btn-success" [disabled]="createForm.invalid">Create Client</button>
</form>
</div>
</div>
</div>
</div>
</div>

44
src/Squidex/app/components/internal/app/settings/clients-page.component.scss

@ -0,0 +1,44 @@
@import '_vars';
@import '_mixins';
.layout-title-icon {
color: $color-section-settings;
}
.card {
& {
max-width: 700px;
}
&-block {
padding-left: .5rem;
padding-right: .5rem;
}
}
.clients {
&-empty {
color: $color-empty;
font-size: 1.2rem;
font-weight: lighter;
padding: .2rem 1rem;
}
&-table {
margin: 0;
}
}
.client {
&-delete {
margin-top: .3rem;
}
&-info {
margin: 0;
}
&-expires {
padding: .3rem .8rem;
}
}

107
src/Squidex/app/components/internal/app/settings/clients-page.component.ts

@ -0,0 +1,107 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Sebastian Stehle. All rights reserved
*/
import * as Ng2 from '@angular/core';
import * as Ng2Forms from '@angular/forms';
import {
AppsStoreService,
AppClientDto,
AppClientCreateDto,
AppClientsService,
fadeAnimation,
TitleService
} from 'shared';
@Ng2.Component({
selector: 'sqx-clients-page',
styles,
template,
animations: [
fadeAnimation()
]
})
export class ClientsPageComponent implements Ng2.OnInit {
private appSubscription: any | null = null;
private appName: string | null = null;
public appClients: AppClientDto[];
public creationError = '';
public createForm =
this.formBuilder.group({
name: ['',
[
Ng2Forms.Validators.required,
Ng2Forms.Validators.maxLength(40),
Ng2Forms.Validators.pattern('[a-z0-9]+(\-[a-z0-9]+)*')
]]
});
constructor(
private readonly titles: TitleService,
private readonly appsStore: AppsStoreService,
private readonly appClientsService: AppClientsService,
private readonly formBuilder: Ng2Forms.FormBuilder
) {
}
public ngOnInit() {
this.appSubscription =
this.appsStore.selectedApp.subscribe(app => {
if (app) {
this.appName = app.name;
this.titles.setTitle('{appName} | Settings | Clients', { appName: app.name });
this.appClientsService.getClients(app.name).subscribe(clients => {
this.appClients = clients;
});
}
});
}
public ngOnDestroy() {
this.appSubscription.unsubscribe();
}
public fullAppName(client: AppClientDto): string {
return this.appName + ':' + client.clientName;
}
public revokeClient(client: AppClientDto) {
this.appClientsService.deleteClient(this.appName, client.clientName).subscribe();
this.appClients.splice(this.appClients.indexOf(client), 1);
}
public attachClient() {
this.createForm.markAsDirty();
if (this.createForm.valid) {
this.createForm.disable();
const dto = new AppClientCreateDto(this.createForm.controls['name'].value);
this.appClientsService.postClient(this.appName, dto)
.subscribe(client => {
this.reset();
this.appClients.push(client);
}, error => {
this.reset();
this.creationError = error;
});
}
}
private reset() {
this.createForm.reset();
this.createForm.enable();
this.creationError = '';
}
}

6
src/Squidex/app/components/internal/app/settings/contributors-page.component.html

@ -11,7 +11,7 @@
<div class="layout-middle-content"> <div class="layout-middle-content">
<div class="card"> <div class="card">
<div class="card-block"> <div class="card-block">
<table class="table table-borderless table-fixed"> <table class="table table-borderless-top table-fixed contributors-table">
<colgroup> <colgroup>
<col style="width: 100%" /> <col style="width: 100%" />
<col style="width: 150px" /> <col style="width: 150px" />
@ -41,7 +41,7 @@
</table> </table>
</div> </div>
<div class="card-footer"> <div class="card-footer">
<form class="form-inline" (submit)="addContributor()" > <form class="form-inline" (submit)="assignContributor()" >
<div class="form-group"> <div class="form-group">
<ng2-completer <ng2-completer
[autoMatch]="true" [autoMatch]="true"
@ -58,7 +58,7 @@
</ng2-completer> </ng2-completer>
</div> </div>
<button type="submit" class="btn btn-primary" [disabled]="!selectedUser">Add</button> <button type="submit" class="btn btn-success" [disabled]="!selectedUser">Add Contributor</button>
</form> </form>
</div> </div>
</div> </div>

6
src/Squidex/app/components/internal/app/settings/contributors-page.component.scss

@ -9,6 +9,12 @@
max-width: 700px; max-width: 700px;
} }
.contributors {
&-table {
margin: 0;
}
}
.user { .user {
&-picture { &-picture {
@include circle(2.2rem); @include circle(2.2rem);

2
src/Squidex/app/components/internal/app/settings/contributors-page.component.ts

@ -119,7 +119,7 @@ export class ContributorsPageComponent implements Ng2.OnInit {
this.appSubscription.unsubscribe(); this.appSubscription.unsubscribe();
} }
public addContributor() { public assignContributor() {
if (!this.selectedUser) { if (!this.selectedUser) {
return; return;
} }

40
src/Squidex/app/components/internal/app/settings/credentials-page.component.html

@ -1,40 +0,0 @@
<div class="layout">
<div class="layout-left">
<sqx-left-menu></sqx-left-menu>
</div>
<div class="layout-middle">
<div class="layout-middle-header">
<h1>
<i class="layout-title-icon icon-settings"></i> Credentials
</h1>
</div>
<div class="layout-middle-content">
<div class="card">
<div class="card-block">
<table class="table table-borderless table-fixed">
<colgroup>
<col style="width: 100%" />
<col style="width: 160px" />
<col style="width: 120px" />
</colgroup>
<tr *ngFor="let clientKey of appClientKeys">
<td>
<input readonly class="form-control" [ngModel]="clientKey.clientKey" />
</td>
<td>
<button class="btn btn-block btn-default">Create Token</button>
</td>
<td>
<button class="btn btn-block btn-danger">Revoke</button>
</td>
</tr>
</table>
</div>
<div class="card-footer">
<button type="submit" class="btn btn-primary" (click)="createClientKey()">Create Client Key</button>
</div>
</div>
</div>
</div>
</div>

17
src/Squidex/app/components/internal/app/settings/credentials-page.component.scss

@ -1,17 +0,0 @@
@import '_vars';
@import '_mixins';
.layout-title-icon {
color: $color-section-settings;
}
.card {
& {
max-width: 700px;
}
&-block {
padding-left: .5rem;
padding-right: .5rem;
}
}

60
src/Squidex/app/components/internal/app/settings/credentials-page.component.ts

@ -1,60 +0,0 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Sebastian Stehle. All rights reserved
*/
import * as Ng2 from '@angular/core';
import {
AppsStoreService,
AppClientKeyDto,
AppClientKeysService,
TitleService
} from 'shared';
@Ng2.Component({
selector: 'sqx-credentials-page',
styles,
template
})
export class CredentialsPageComponent implements Ng2.OnInit {
private appSubscription: any | null = null;
private appName: string | null = null;
public appClientKeys: AppClientKeyDto[] = [];
constructor(
private readonly titles: TitleService,
private readonly appsStore: AppsStoreService,
private readonly appClientKeysService: AppClientKeysService
) {
}
public ngOnInit() {
this.appSubscription =
this.appsStore.selectedApp.subscribe(app => {
if (app) {
this.appName = app.name;
this.titles.setTitle('{appName} | Settings | Credentials', { appName: app.name });
this.appClientKeysService.getClientKeys(app.name).subscribe(clientKeys => {
this.appClientKeys = clientKeys;
});
}
});
}
public ngOnDestroy() {
this.appSubscription.unsubscribe();
}
public createClientKey() {
this.appClientKeysService.postClientKey(this.appName).subscribe(clientKey => {
this.appClientKeys.push(clientKey);
})
}
}

10
src/Squidex/app/components/internal/app/settings/languages-page.component.html

@ -4,6 +4,10 @@
</div> </div>
<div class="layout-middle"> <div class="layout-middle">
<div class="layout-middle-header"> <div class="layout-middle-header">
<div class="pull-right">
<button class="btn btn-success" (click)="saveLanguages()" [disabled]="isSaving">{{isSaving ? 'Saving...' : 'Save Changes'}}</button>
</div>
<h1> <h1>
<i class="layout-title-icon icon-settings"></i> Languages <i class="layout-title-icon icon-settings"></i> Languages
</h1> </h1>
@ -11,7 +15,7 @@
<div class="layout-middle-content"> <div class="layout-middle-content">
<div class="card"> <div class="card">
<div class="card-block"> <div class="card-block">
<table class="table table-borderless table-fixed" dnd-sortable-container [sortableData]="appLanguages"> <table class="table table-borderless-top table-fixed languages-table" dnd-sortable-container [sortableData]="appLanguages">
<colgroup> <colgroup>
<col style="width: 60px" /> <col style="width: 60px" />
<col style="width: 100%" /> <col style="width: 100%" />
@ -34,8 +38,6 @@
</td> </td>
</tr> </tr>
</table> </table>
<button class="btn btn-primary" (click)="saveLanguages()" [disabled]="isSaving">{{isSaving ? 'Saving...' : 'Save'}}</button>
</div> </div>
<div class="card-footer"> <div class="card-footer">
<form class="form-inline" (submit)="addLanguage()" name="addLanguageForm"> <form class="form-inline" (submit)="addLanguage()" name="addLanguageForm">
@ -45,7 +47,7 @@
</select> </select>
</div> </div>
<button type="submit" class="btn btn-primary" [disabled]="!selectedLanguage">Add</button> <button type="submit" class="btn btn-success" [disabled]="!selectedLanguage">Add</button>
</form> </form>
</div> </div>
</div> </div>

6
src/Squidex/app/components/internal/app/settings/languages-page.component.scss

@ -9,6 +9,12 @@
max-width: 700px; max-width: 700px;
} }
.languages {
&-table {
margin: 0;
}
}
.language { .language {
&-select { &-select {
max-width: 200px; max-width: 200px;

2
src/Squidex/app/components/internal/declarations.ts

@ -10,8 +10,8 @@ export * from './app/app-area.component';
export * from './app/left-menu.component'; export * from './app/left-menu.component';
export * from './app/dashboard/dashboard-page.component'; export * from './app/dashboard/dashboard-page.component';
export * from './app/schemas/schemas-page.component'; export * from './app/schemas/schemas-page.component';
export * from './app/settings/clients-page.component';
export * from './app/settings/contributors-page.component'; export * from './app/settings/contributors-page.component';
export * from './app/settings/credentials-page.component';
export * from './app/settings/languages-page.component'; export * from './app/settings/languages-page.component';
export * from './internal-area.component'; export * from './internal-area.component';

4
src/Squidex/app/components/internal/module.ts

@ -17,8 +17,8 @@ import { SqxLayoutModule } from 'components/layout';
import { import {
AppAreaComponent, AppAreaComponent,
AppsPageComponent, AppsPageComponent,
ClientsPageComponent,
ContributorsPageComponent, ContributorsPageComponent,
CredentialsPageComponent,
DashboardPageComponent, DashboardPageComponent,
InternalAreaComponent, InternalAreaComponent,
LeftMenuComponent, LeftMenuComponent,
@ -39,8 +39,8 @@ import {
declarations: [ declarations: [
AppAreaComponent, AppAreaComponent,
AppsPageComponent, AppsPageComponent,
ClientsPageComponent,
ContributorsPageComponent, ContributorsPageComponent,
CredentialsPageComponent,
DashboardPageComponent, DashboardPageComponent,
InternalAreaComponent, InternalAreaComponent,
LanguagesPageComponent, LanguagesPageComponent,

5
src/Squidex/app/components/layout/app-form.component.html

@ -1,4 +1,4 @@
<form [formGroup]="createForm" (ngSubmit)="submit()"> <form [formGroup]="createForm" (ngSubmit)="createApp()">
<div *ngIf="creationError" [@fade]> <div *ngIf="creationError" [@fade]>
<div class="form-error"> <div class="form-error">
{{creationError}} {{creationError}}
@ -17,7 +17,7 @@
Name can not have more than 40 characters. Name can not have more than 40 characters.
</span> </span>
<span *ngIf="createForm.get('name').hasError('pattern')"> <span *ngIf="createForm.get('name').hasError('pattern')">
Name can contain lower case letters (a-z), numbers and dashes only. Name can contain lower case letters (a-z), numbers and dashes only (not at the end).
</span> </span>
</div> </div>
</div> </div>
@ -26,6 +26,7 @@
<span class="form-hint"> <span class="form-hint">
The app name becomes part of the api url, e.g, https://<b>{{appName}}</b>.squidex.io/.<br /> The app name becomes part of the api url, e.g, https://<b>{{appName}}</b>.squidex.io/.<br />
It must contain lower case letters (a-z), numbers and dashes only, and cannot be longer than 40 characters. The name cannot be changed later. It must contain lower case letters (a-z), numbers and dashes only, and cannot be longer than 40 characters. The name cannot be changed later.
</span> </span>
</div> </div>

8
src/Squidex/app/components/layout/app-form.component.ts

@ -34,6 +34,7 @@ export class AppFormComponent implements Ng2.OnInit {
@Ng2.Output() @Ng2.Output()
public cancelled = new Ng2.EventEmitter(); public cancelled = new Ng2.EventEmitter();
public creationError = '';
public createForm = public createForm =
this.formBuilder.group({ this.formBuilder.group({
name: ['', name: ['',
@ -46,9 +47,6 @@ export class AppFormComponent implements Ng2.OnInit {
public appName = FALLBACK_NAME; public appName = FALLBACK_NAME;
public creating = false;
public creationError = '';
constructor( constructor(
private readonly appsStore: AppsStoreService, private readonly appsStore: AppsStoreService,
private readonly formBuilder: Ng2Forms.FormBuilder private readonly formBuilder: Ng2Forms.FormBuilder
@ -61,12 +59,11 @@ export class AppFormComponent implements Ng2.OnInit {
}); });
} }
public submit() { public createApp() {
this.createForm.markAsDirty(); this.createForm.markAsDirty();
if (this.createForm.valid) { if (this.createForm.valid) {
this.createForm.disable(); this.createForm.disable();
this.creating = true;
const dto = new AppCreateDto(this.createForm.controls['name'].value); const dto = new AppCreateDto(this.createForm.controls['name'].value);
@ -83,7 +80,6 @@ export class AppFormComponent implements Ng2.OnInit {
private reset() { private reset() {
this.createForm.enable(); this.createForm.enable();
this.creating = false;
this.creationError = ''; this.creationError = '';
} }

2
src/Squidex/app/shared/index.ts

@ -9,7 +9,7 @@ export * from './guards/app-must-exist.guard';
export * from './guards/must-be-authenticated.guard'; export * from './guards/must-be-authenticated.guard';
export * from './guards/must-be-not-authenticated.guard'; export * from './guards/must-be-not-authenticated.guard';
export * from './services/app-contributors.service'; export * from './services/app-contributors.service';
export * from './services/app-client-keys.service'; export * from './services/app-clients.service';
export * from './services/app-languages.service'; export * from './services/app-languages.service';
export * from './services/apps-store.service'; export * from './services/apps-store.service';
export * from './services/apps.service'; export * from './services/apps.service';

52
src/Squidex/app/shared/services/app-client-keys.service.ts

@ -1,52 +0,0 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Sebastian Stehle. All rights reserved
*/
import * as Ng2 from '@angular/core';
import * as Ng2Http from '@angular/http';
import { Observable } from 'rxjs';
import { ApiUrlConfig, DateTime } from 'framework';
import { AuthService } from './auth.service';
export class AppClientKeyDto {
constructor(
public readonly clientKey: string,
public readonly expiresUtc: DateTime
) {
}
}
@Ng2.Injectable()
export class AppClientKeysService {
constructor(
private readonly authService: AuthService,
private readonly apiUrl: ApiUrlConfig,
private readonly http: Ng2Http.Http
) {
}
public getClientKeys(appName: string): Observable<AppClientKeyDto[]> {
return this.authService.authGet(this.apiUrl.buildUrl(`api/apps/${appName}/client-keys`))
.map(response => {
const body: any[] = response.json();
return body.map(item => {
return new AppClientKeyDto(item.clientKey, DateTime.parseISO_UTC(item.expiresUtc));
});
});
}
public postClientKey(appName: string): Observable<AppClientKeyDto> {
return this.authService.authPost(this.apiUrl.buildUrl(`api/apps/${appName}/client-keys`), {})
.map(response => {
const body = response.json();
return new AppClientKeyDto(body.clientKey, DateTime.now().addYears(1));
});
}
}

107
src/Squidex/app/shared/services/app-clients.service.spec.ts

@ -0,0 +1,107 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Sebastian Stehle. All rights reserved
*/
import * as TypeMoq from 'typemoq';
import * as Ng2Http from '@angular/http';
import { Observable } from 'rxjs';
import {
ApiUrlConfig,
AppClientDto,
AppClientsService,
AuthService,
AppClientCreateDto,
DateTime
} from './../';
describe('AppClientsService', () => {
let authService: TypeMoq.Mock<AuthService>;
let appClientsService: AppClientsService;
beforeEach(() => {
authService = TypeMoq.Mock.ofType(AuthService);
appClientsService = new AppClientsService(authService.object, new ApiUrlConfig('http://service/p/'));
});
it('should make get request with auth service to get app clients', () => {
authService.setup(x => x.authGet('http://service/p/api/apps/my-app/clients'))
.returns(() => Observable.of(
new Ng2Http.Response(
new Ng2Http.ResponseOptions({
body: [{
clientName: 'client1',
clientSecret: 'secret1',
expiresUtc: '2016-12-12T10:10'
}, {
clientName: 'client2',
clientSecret: 'secret2',
expiresUtc: '2016-11-11T10:10'
}]
})
)
))
.verifiable(TypeMoq.Times.once());
let clients: AppClientDto[] = null;
appClientsService.getClients('my-app').subscribe(result => {
clients = result;
}).unsubscribe();
expect(clients).toEqual(
[
new AppClientDto('client1', 'secret1', DateTime.parseISO_UTC('2016-12-12T10:10')),
new AppClientDto('client2', 'secret2', DateTime.parseISO_UTC('2016-11-11T10:10')),
]);
authService.verifyAll();
});
it('should make post request to create client', () => {
const createClient = new AppClientCreateDto('client1');
authService.setup(x => x.authPost('http://service/p/api/apps/my-app/clients', TypeMoq.It.is(c => c === createClient)))
.returns(() => Observable.of(
new Ng2Http.Response(
new Ng2Http.ResponseOptions({
body: {
clientName: 'client1',
clientSecret: 'secret1',
expiresUtc: '2016-12-12T10:10'
}
})
)
))
.verifiable(TypeMoq.Times.once());
let client: AppClientDto = null;
appClientsService.postClient('my-app', createClient).subscribe(result => {
client = result;
});
expect(client).toEqual(
new AppClientDto('client1', 'secret1', DateTime.parseISO_UTC('2016-12-12T10:10')));
authService.verifyAll();
});
it('should make delete request to remove client', () => {
authService.setup(x => x.authDelete('http://service/p/api/apps/my-app/clients/client1'))
.returns(() => Observable.of(
new Ng2Http.Response(
new Ng2Http.ResponseOptions()
)
))
.verifiable(TypeMoq.Times.once());
appClientsService.deleteClient('my-app', 'client1');
authService.verifyAll();
});
});

67
src/Squidex/app/shared/services/app-clients.service.ts

@ -0,0 +1,67 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Sebastian Stehle. All rights reserved
*/
import * as Ng2 from '@angular/core';
import { Observable } from 'rxjs';
import { ApiUrlConfig, DateTime } from 'framework';
import { AuthService } from './auth.service';
export class AppClientDto {
constructor(
public readonly clientName: string,
public readonly clientSecret: string,
public readonly expiresUtc: DateTime
) {
}
}
export class AppClientCreateDto {
constructor(
public readonly clientName: string
) {
}
}
@Ng2.Injectable()
export class AppClientsService {
constructor(
private readonly authService: AuthService,
private readonly apiUrl: ApiUrlConfig
) {
}
public getClients(appName: string): Observable<AppClientDto[]> {
return this.authService.authGet(this.apiUrl.buildUrl(`api/apps/${appName}/clients`))
.map(response => response.json())
.map(response => {
const items: any[] = response;
return items.map(item => {
return new AppClientDto(item.clientName, item.clientSecret, DateTime.parseISO_UTC(item.expiresUtc));
});
});
}
public postClient(appName: string, client: AppClientCreateDto): Observable<AppClientDto> {
return this.authService.authPost(this.apiUrl.buildUrl(`api/apps/${appName}/clients`), client)
.map(response => response.json())
.map(response => new AppClientDto(response.clientName, response.clientSecret, DateTime.parseISO_UTC(response.expiresUtc)))
.catch(response => {
if (response.status === 400) {
return Observable.throw('An client with the same name already exists.');
} else {
return Observable.throw('A new client could not be created.');
}
});
}
public deleteClient(appName: string, name: string): Observable<any> {
return this.authService.authDelete(this.apiUrl.buildUrl(`api/apps/${appName}/clients/${name}`));
}
}

5
src/Squidex/app/shared/services/app-contributors.service.ts

@ -30,10 +30,11 @@ export class AppContributorsService {
public getContributors(appName: string): Observable<AppContributorDto[]> { public getContributors(appName: string): Observable<AppContributorDto[]> {
return this.authService.authGet(this.apiUrl.buildUrl(`api/apps/${appName}/contributors`)) return this.authService.authGet(this.apiUrl.buildUrl(`api/apps/${appName}/contributors`))
.map(response => response.json())
.map(response => { .map(response => {
const body: any[] = response.json(); const items: any[] = response;
return body.map(item => { return items.map(item => {
return new AppContributorDto( return new AppContributorDto(
item.contributorId, item.contributorId,
item.permission); item.permission);

6
src/Squidex/app/shared/services/app-languages.service.ts

@ -23,10 +23,11 @@ export class AppLanguagesService {
public getLanguages(appName: string): Observable<LanguageDto[]> { public getLanguages(appName: string): Observable<LanguageDto[]> {
return this.authService.authGet(this.apiUrl.buildUrl(`api/apps/${appName}/languages`)) return this.authService.authGet(this.apiUrl.buildUrl(`api/apps/${appName}/languages`))
.map(response => response.json())
.map(response => { .map(response => {
const body: any[] = response.json(); const items: any[] = response;
return body.map(item => { return items.map(item => {
return new LanguageDto( return new LanguageDto(
item.iso2Code, item.iso2Code,
item.englishName); item.englishName);
@ -35,7 +36,6 @@ export class AppLanguagesService {
} }
public postLanguages(appName: string, languageCodes: string[]): Observable<any> { public postLanguages(appName: string, languageCodes: string[]): Observable<any> {
languageCodes = [ null, null ];
return this.authService.authPost(this.apiUrl.buildUrl(`api/apps/${appName}/languages`), { languages: languageCodes }); return this.authService.authPost(this.apiUrl.buildUrl(`api/apps/${appName}/languages`), { languages: languageCodes });
} }
} }

5
src/Squidex/app/shared/services/apps.service.ts

@ -39,10 +39,11 @@ export class AppsService {
public getApps(): Observable<AppDto[]> { public getApps(): Observable<AppDto[]> {
return this.authService.authGet(this.apiUrl.buildUrl('/api/apps')) return this.authService.authGet(this.apiUrl.buildUrl('/api/apps'))
.map(response => response.json())
.map(response => { .map(response => {
const body: any[] = response.json() || []; const items: any[] = response;
return body.map(item => { return items.map(item => {
return new AppDto( return new AppDto(
item.id, item.id,
item.name, item.name,

5
src/Squidex/app/shared/services/languages.service.ts

@ -29,10 +29,11 @@ export class LanguageService {
public getLanguages(): Observable<LanguageDto[]> { public getLanguages(): Observable<LanguageDto[]> {
return this.authService.authGet(this.apiUrl.buildUrl('api/languages')) return this.authService.authGet(this.apiUrl.buildUrl('api/languages'))
.map(response => response.json())
.map(response => { .map(response => {
const body: any[] = response.json(); const items: any[] = response;
return body.map(item => { return items.map(item => {
return new LanguageDto( return new LanguageDto(
item.iso2Code, item.iso2Code,
item.englishName); item.englishName);

16
src/Squidex/app/shared/services/users.service.ts

@ -32,10 +32,11 @@ export class UsersService {
public getUsers(query?: string): Observable<UserDto[]> { public getUsers(query?: string): Observable<UserDto[]> {
return this.authService.authGet(this.apiUrl.buildUrl(`api/users/?query=${query || ''}`)) return this.authService.authGet(this.apiUrl.buildUrl(`api/users/?query=${query || ''}`))
.map(response => response.json())
.map(response => { .map(response => {
const body: any[] = response.json() || []; const items: any[] = response;
return body.map(item => { return items.map(item => {
return new UserDto( return new UserDto(
item.id, item.id,
item.email, item.email,
@ -47,14 +48,13 @@ export class UsersService {
public getUser(id: string): Observable<UserDto> { public getUser(id: string): Observable<UserDto> {
return this.authService.authGet(this.apiUrl.buildUrl(`api/users/${id}`)) return this.authService.authGet(this.apiUrl.buildUrl(`api/users/${id}`))
.map(response => response.json())
.map(response => { .map(response => {
const body: any = response.json();
return new UserDto( return new UserDto(
body.id, response.id,
body.email, response.email,
body.displayName, response.displayName,
body.pictureUrl); response.pictureUrl);
}); });
} }
} }

14
src/Squidex/app/theme/_bootstrap.scss

@ -8,7 +8,19 @@
&-borderless { &-borderless {
td { td {
border: none; border: 0;
}
}
&-borderless-top {
tr:first-child td {
border: 0;
}
}
&-middle {
td {
vertical-align: middle;
} }
} }
} }

1
src/Squidex/app/theme/_vars.scss

@ -4,6 +4,7 @@ $color-background: #f4f8f9;
$color-border: #eaeeef; $color-border: #eaeeef;
$color-text: #373a3c; $color-text: #373a3c;
$color-empty: #777;
$color-modal-header-background: #1d262f; $color-modal-header-background: #1d262f;
$color-modal-header-foreground: #a8aaac; $color-modal-header-foreground: #a8aaac;

2
src/Squidex/web.config

@ -2,7 +2,7 @@
<configuration> <configuration>
<system.webServer> <system.webServer>
<handlers> <handlers>
<add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModule" resourceType="Unspecified"/> <add client="aspNetCore" path="*" verb="*" modules="AspNetCoreModule" resourceType="Unspecified"/>
</handlers> </handlers>
<aspNetCore processPath="%LAUNCHER_PATH%" arguments="%LAUNCHER_ARGS%" stdoutLogEnabled="true" stdoutLogFile="C:\\Logs\squidex\stdout" forwardWindowsAuthToken="false"/> <aspNetCore processPath="%LAUNCHER_PATH%" arguments="%LAUNCHER_ARGS%" stdoutLogEnabled="true" stdoutLogFile="C:\\Logs\squidex\stdout" forwardWindowsAuthToken="false"/>
</system.webServer> </system.webServer>

97
tests/Squidex.Infrastructure.Tests/CQRS/Commands/CommandContextTests.cs

@ -0,0 +1,97 @@
// ==========================================================================
// CommandContextTests.cs
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex Group
// All rights reserved.
// ==========================================================================
using System;
using Xunit;
namespace Squidex.Infrastructure.CQRS.Commands
{
public class CommandContextTests
{
private readonly MockupCommand command = new MockupCommand();
private sealed class MockupCommand : ICommand
{
}
[Fact]
public void Should_instantiate_and_provide_command()
{
var sut = new CommandContext(command);
Assert.Equal(command, sut.Command);
Assert.Null(sut.Exception);
Assert.False(sut.IsSucceeded);
Assert.False(sut.IsHandled);
}
[Fact]
public void Should_provide_exception_when_failed()
{
var exc = new InvalidOperationException();
var sut = new CommandContext(command);
sut.Fail(exc);
Assert.Equal(exc, sut.Exception);
Assert.False(sut.IsSucceeded);
Assert.True(sut.IsHandled);
}
[Fact]
public void Should_be_handled_when_succeeded()
{
var sut = new CommandContext(command);
sut.Succeed();
Assert.Null(sut.Exception);
Assert.True(sut.IsSucceeded);
Assert.True(sut.IsHandled);
}
[Fact]
public void Shoud_not_change_status_when_already_succeeded()
{
var sut = new CommandContext(command);
sut.Succeed(Guid.NewGuid());
sut.Fail(new Exception());
Assert.Null(sut.Exception);
Assert.True(sut.IsHandled);
Assert.True(sut.IsSucceeded);
}
[Fact]
public void Should_provide_result_valid_when_succeeded_with_value()
{
var guid = Guid.NewGuid();
var sut = new CommandContext(command);
sut.Succeed(guid);
Assert.Equal(guid, sut.Result<Guid>());
Assert.True(sut.IsSucceeded);
Assert.True(sut.IsHandled);
}
[Fact]
public void Shoud_not_change_status_when_already_failed()
{
var sut = new CommandContext(command);
sut.Fail(new Exception());
sut.Succeed(Guid.NewGuid());
Assert.NotNull(sut.Exception);
Assert.True(sut.IsHandled);
Assert.False(sut.IsSucceeded);
}
}
}

76
tests/Squidex.Write.Tests/Apps/AppCommandHandlerTests.cs

@ -6,10 +6,12 @@
// All rights reserved. // All rights reserved.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using Moq; using Moq;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.CQRS.Commands;
using Squidex.Read.Apps; using Squidex.Read.Apps;
using Squidex.Read.Apps.Repositories; using Squidex.Read.Apps.Repositories;
using Squidex.Read.Users; using Squidex.Read.Users;
@ -18,16 +20,24 @@ using Squidex.Write.Apps;
using Squidex.Write.Apps.Commands; using Squidex.Write.Apps.Commands;
using Squidex.Write.Tests.Utils; using Squidex.Write.Tests.Utils;
using Xunit; using Xunit;
using FluentAssertions;
// ReSharper disable ImplicitlyCapturedClosure // ReSharper disable ImplicitlyCapturedClosure
// ReSharper disable ConvertToConstant.Local
namespace Squidex.Write.Tests.Apps namespace Squidex.Write.Tests.Apps
{ {
public class AppCommandHandlerTests : HandlerTestBase<AppDomainObject> public class AppCommandHandlerTests : HandlerTestBase<AppDomainObject>
{ {
private readonly Mock<ClientKeyGenerator> keyGenerator = new Mock<ClientKeyGenerator>();
private readonly Mock<IAppRepository> appRepository = new Mock<IAppRepository>(); private readonly Mock<IAppRepository> appRepository = new Mock<IAppRepository>();
private readonly Mock<IUserRepository> userRepository = new Mock<IUserRepository>(); private readonly Mock<IUserRepository> userRepository = new Mock<IUserRepository>();
private readonly AppCommandHandler sut; private readonly AppCommandHandler sut;
private readonly AppDomainObject app; private readonly AppDomainObject app;
private readonly string subjectId = Guid.NewGuid().ToString();
private readonly string contributorId = Guid.NewGuid().ToString();
private readonly string clientSecret = Guid.NewGuid().ToString();
private readonly string clientName = "client";
private readonly string appName = "my-app";
public AppCommandHandlerTests() public AppCommandHandlerTests()
{ {
@ -36,18 +46,22 @@ namespace Squidex.Write.Tests.Apps
sut = new AppCommandHandler( sut = new AppCommandHandler(
DomainObjectFactory.Object, DomainObjectFactory.Object,
DomainObjectRepository.Object, DomainObjectRepository.Object,
userRepository.Object,
appRepository.Object, appRepository.Object,
userRepository.Object); keyGenerator.Object);
} }
[Fact] [Fact]
public async Task Create_should_throw_if_a_name_with_same_name_already_exists() public async Task Create_should_throw_if_a_name_with_same_name_already_exists()
{ {
appRepository.Setup(x => x.FindAppByNameAsync("my-app")).Returns(Task.FromResult(new Mock<IAppEntity>().Object)).Verifiable(); var command = new CreateApp { Name = appName, AggregateId = Id, SubjectId = subjectId };
var context = new CommandContext(command);
appRepository.Setup(x => x.FindAppByNameAsync(appName)).Returns(Task.FromResult(new Mock<IAppEntity>().Object)).Verifiable();
await TestCreate(app, async _ => await TestCreate(app, async _ =>
{ {
await Assert.ThrowsAsync<ValidationException>(async () => await sut.On(new CreateApp { Name = "my-app" })); await Assert.ThrowsAsync<ValidationException>(async () => await sut.HandleAsync(context));
}, false); }, false);
appRepository.VerifyAll(); appRepository.VerifyAll();
@ -56,16 +70,17 @@ namespace Squidex.Write.Tests.Apps
[Fact] [Fact]
public async Task Create_should_create_app_if_name_is_free() public async Task Create_should_create_app_if_name_is_free()
{ {
var command = new CreateApp { Name = "my-app", AggregateId = Id, SubjectId = "456" }; var command = new CreateApp { Name = appName, AggregateId = Id, SubjectId = subjectId };
var context = new CommandContext(command);
appRepository.Setup(x => x.FindAppByNameAsync("my-app")).Returns(Task.FromResult<IAppEntity>(null)).Verifiable(); appRepository.Setup(x => x.FindAppByNameAsync(appName)).Returns(Task.FromResult<IAppEntity>(null)).Verifiable();
await TestCreate(app, async _ => await TestCreate(app, async _ =>
{ {
await sut.On(command); await sut.HandleAsync(context);
}); });
appRepository.VerifyAll(); Assert.Equal(command.AggregateId, context.Result<Guid>());
} }
[Fact] [Fact]
@ -74,10 +89,11 @@ namespace Squidex.Write.Tests.Apps
CreateApp(); CreateApp();
var command = new ConfigureLanguages { AggregateId = Id, Languages = new List<Language> { Language.GetLanguage("de") } }; var command = new ConfigureLanguages { AggregateId = Id, Languages = new List<Language> { Language.GetLanguage("de") } };
var context = new CommandContext(command);
await TestUpdate(app, async _ => await TestUpdate(app, async _ =>
{ {
await sut.On(command); await sut.HandleAsync(context);
}); });
} }
@ -86,13 +102,14 @@ namespace Squidex.Write.Tests.Apps
{ {
CreateApp(); CreateApp();
var command = new AssignContributor { AggregateId = Id, ContributorId = "456" }; var command = new AssignContributor { AggregateId = Id, ContributorId = contributorId };
var context = new CommandContext(command);
userRepository.Setup(x => x.FindUserByIdAsync(command.ContributorId)).Returns(Task.FromResult<IUserEntity>(null)); userRepository.Setup(x => x.FindUserByIdAsync(command.ContributorId)).Returns(Task.FromResult<IUserEntity>(null));
await TestUpdate(app, async _ => await TestUpdate(app, async _ =>
{ {
await Assert.ThrowsAsync<ValidationException>(() => sut.On(command)); await Assert.ThrowsAsync<ValidationException>(() => sut.HandleAsync(context));
}, false); }, false);
} }
@ -101,13 +118,14 @@ namespace Squidex.Write.Tests.Apps
{ {
CreateApp(); CreateApp();
var command = new AssignContributor { AggregateId = Id, ContributorId = "456" }; var command = new AssignContributor { AggregateId = Id, ContributorId = contributorId };
var context = new CommandContext(command);
userRepository.Setup(x => x.FindUserByIdAsync(command.ContributorId)).Returns(Task.FromResult(new Mock<IUserEntity>().Object)); userRepository.Setup(x => x.FindUserByIdAsync(command.ContributorId)).Returns(Task.FromResult(new Mock<IUserEntity>().Object));
await TestUpdate(app, async _ => await TestUpdate(app, async _ =>
{ {
await sut.On(command); await sut.HandleAsync(context);
}); });
} }
@ -115,46 +133,58 @@ namespace Squidex.Write.Tests.Apps
public async Task RemoveContributor_should_update_domain_object() public async Task RemoveContributor_should_update_domain_object()
{ {
CreateApp() CreateApp()
.AssignContributor(new AssignContributor { ContributorId = "456" }); .AssignContributor(new AssignContributor { ContributorId = contributorId });
var command = new RemoveContributor { AggregateId = Id, ContributorId = "456" }; var command = new RemoveContributor { AggregateId = Id, ContributorId = contributorId };
var context = new CommandContext(command);
await TestUpdate(app, async _ => await TestUpdate(app, async _ =>
{ {
await sut.On(command); await sut.HandleAsync(context);
}); });
} }
[Fact] [Fact]
public async Task CreateClientKey_should_update_domain_object() public async Task AttachClient_should_update_domain_object()
{ {
keyGenerator.Setup(x => x.GenerateKey()).Returns(clientSecret).Verifiable();
CreateApp(); CreateApp();
var command = new CreateClientKey { AggregateId = Id, ClientKey = "456" }; var timestamp = DateTime.Today;
var command = new AttachClient { ClientName = clientName, AggregateId = Id, Timestamp = timestamp };
var context = new CommandContext(command);
await TestUpdate(app, async _ => await TestUpdate(app, async _ =>
{ {
await sut.On(command); await sut.HandleAsync(context);
}); });
keyGenerator.VerifyAll();
context.Result<AppClient>().ShouldBeEquivalentTo(
new AppClient(clientName, clientSecret, timestamp.AddYears(1)));
} }
[Fact] [Fact]
public async Task RevokeClientKey_should_update_domain_object() public async Task RevokeClient_should_update_domain_object()
{ {
CreateApp() CreateApp()
.CreateClientKey(new CreateClientKey { ClientKey = "456" }); .AttachClient(new AttachClient { ClientName = clientName }, clientSecret);
var command = new RevokeClientKey { AggregateId = Id, ClientKey = "456" }; var command = new RevokeClient { AggregateId = Id, ClientName = clientName };
var context = new CommandContext(command);
await TestUpdate(app, async _ => await TestUpdate(app, async _ =>
{ {
await sut.On(command); await sut.HandleAsync(context);
}); });
} }
private AppDomainObject CreateApp() private AppDomainObject CreateApp()
{ {
app.Create(new CreateApp { Name = "my-app", SubjectId = "123" }); app.Create(new CreateApp { Name = appName, SubjectId = subjectId });
return app; return app;
} }

54
tests/Squidex.Write.Tests/Apps/AppDomainObjectTests.cs

@ -18,6 +18,7 @@ using Squidex.Infrastructure.CQRS.Events;
using Squidex.Write.Apps; using Squidex.Write.Apps;
using Squidex.Write.Apps.Commands; using Squidex.Write.Apps.Commands;
using Xunit; using Xunit;
// ReSharper disable ConvertToConstant.Local
namespace Squidex.Write.Tests.Apps namespace Squidex.Write.Tests.Apps
{ {
@ -27,7 +28,8 @@ namespace Squidex.Write.Tests.Apps
private readonly AppDomainObject sut; private readonly AppDomainObject sut;
private readonly string subjectId = Guid.NewGuid().ToString(); private readonly string subjectId = Guid.NewGuid().ToString();
private readonly string contributorId = Guid.NewGuid().ToString(); private readonly string contributorId = Guid.NewGuid().ToString();
private readonly string clientKey = Guid.NewGuid().ToString(); private readonly string clientSecret = Guid.NewGuid().ToString();
private readonly string clientName = "client";
private readonly List<Language> languages = new List<Language> { Language.GetLanguage("de") }; private readonly List<Language> languages = new List<Language> { Language.GetLanguage("de") };
public AppDomainObjectTests() public AppDomainObjectTests()
@ -63,7 +65,7 @@ namespace Squidex.Write.Tests.Apps
{ {
new AppCreated { Name = TestName }, new AppCreated { Name = TestName },
new AppContributorAssigned { ContributorId = subjectId, Permission = PermissionLevel.Owner }, new AppContributorAssigned { ContributorId = subjectId, Permission = PermissionLevel.Owner },
new AppLanguagesConfigured { Languages= new List<Language> { Language.GetLanguage("de") } } new AppLanguagesConfigured { Languages= new List<Language> { Language.GetLanguage("en") } }
}); });
} }
@ -173,28 +175,38 @@ namespace Squidex.Write.Tests.Apps
} }
[Fact] [Fact]
public void CreateClientKey_should_throw_if_not_created() public void AttachClient_should_throw_if_not_created()
{ {
Assert.Throws<DomainException>(() => sut.CreateClientKey(new CreateClientKey { ClientKey = clientKey })); Assert.Throws<DomainException>(() => sut.AttachClient(new AttachClient { ClientName = clientName }, clientSecret));
} }
[Fact] [Fact]
public void CreateClientKey_should_throw_if_client_key_is_null_or_empty() public void AttachClient_should_throw_if_name_not_valid()
{ {
CreateApp(); CreateApp();
Assert.Throws<ValidationException>(() => sut.CreateClientKey(new CreateClientKey())); Assert.Throws<ValidationException>(() => sut.AttachClient(new AttachClient(), clientSecret));
Assert.Throws<ValidationException>(() => sut.CreateClientKey(new CreateClientKey { ClientKey = string.Empty })); Assert.Throws<ValidationException>(() => sut.AttachClient(new AttachClient { ClientName = string.Empty }, clientSecret));
} }
[Fact] [Fact]
public void CreateClientKey_should_create_events() public void AttachClient_should_throw_if_name_already_exists()
{
CreateApp();
sut.AttachClient(new AttachClient { ClientName = clientName }, clientSecret);
Assert.Throws<ValidationException>(() => sut.AttachClient(new AttachClient { ClientName = clientName }, clientSecret));
}
[Fact]
public void AttachClient_should_create_events()
{ {
var now = DateTime.Today; var now = DateTime.Today;
CreateApp(); CreateApp();
sut.CreateClientKey(new CreateClientKey { ClientKey = clientKey, Timestamp = now }); sut.AttachClient(new AttachClient { ClientName = clientName, Timestamp = now }, clientSecret);
Assert.False(sut.Contributors.ContainsKey(contributorId)); Assert.False(sut.Contributors.ContainsKey(contributorId));
@ -202,46 +214,46 @@ namespace Squidex.Write.Tests.Apps
.ShouldBeEquivalentTo( .ShouldBeEquivalentTo(
new IEvent[] new IEvent[]
{ {
new AppClientKeyCreated { ClientKey = clientKey, ExpiresUtc = now.AddYears(1) } new AppClientAttached { ClientName = clientName, ClientSecret = clientSecret, ExpiresUtc = now.AddYears(1) }
}); });
} }
[Fact] [Fact]
public void RevokeClientKey_should_throw_if_not_created() public void RevokeKey_should_throw_if_not_created()
{ {
Assert.Throws<DomainException>(() => sut.RevokeClientKey(new RevokeClientKey { ClientKey = "not-found" })); Assert.Throws<DomainException>(() => sut.RevokeClient(new RevokeClient { ClientName = "not-found" }));
} }
[Fact] [Fact]
public void RevokeClientKey_should_throw_if_client_key_is_null_or_empty() public void RevokeClient_should_throw_if_client_key_is_null_or_empty()
{ {
CreateApp(); CreateApp();
Assert.Throws<ValidationException>(() => sut.RevokeClientKey(new RevokeClientKey())); Assert.Throws<ValidationException>(() => sut.RevokeClient(new RevokeClient()));
Assert.Throws<ValidationException>(() => sut.RevokeClientKey(new RevokeClientKey { ClientKey = string.Empty })); Assert.Throws<ValidationException>(() => sut.RevokeClient(new RevokeClient { ClientName = string.Empty }));
} }
[Fact] [Fact]
public void RevokeClientKey_should_throw_if_key_not_found() public void RevokeClient_should_throw_if_client_not_found()
{ {
CreateApp(); CreateApp();
Assert.Throws<ValidationException>(() => sut.RevokeClientKey(new RevokeClientKey { ClientKey = "not-found" })); Assert.Throws<ValidationException>(() => sut.RevokeClient(new RevokeClient { ClientName = "not-found" }));
} }
[Fact] [Fact]
public void RevokeClientKey_should_create_events() public void RevokeClient_should_create_events()
{ {
CreateApp(); CreateApp();
sut.CreateClientKey(new CreateClientKey { ClientKey = clientKey }); sut.AttachClient(new AttachClient { ClientName = clientName }, clientSecret);
sut.RevokeClientKey(new RevokeClientKey { ClientKey = clientKey }); sut.RevokeClient(new RevokeClient { ClientName = clientName });
sut.GetUncomittedEvents().Select(x => x.Payload).Skip(1).ToArray() sut.GetUncomittedEvents().Select(x => x.Payload).Skip(1).ToArray()
.ShouldBeEquivalentTo( .ShouldBeEquivalentTo(
new IEvent[] new IEvent[]
{ {
new AppClientKeyRevoked { ClientKey = clientKey } new AppClientRevoked { ClientName = clientSecret }
}); });
} }

Loading…
Cancel
Save