Browse Source

Hateos for Backups.

pull/363/head
Sebastian 7 years ago
parent
commit
7a641431ca
  1. 1
      src/Squidex.Web/PermissionExtensions.cs
  2. 2
      src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs
  3. 7
      src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs
  4. 22
      src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs
  5. 49
      src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobsDto.cs
  6. 1
      src/Squidex/Areas/Api/Controllers/Rules/Models/RuleEventsDto.cs
  7. 5
      src/Squidex/app/features/settings/pages/backups/backups-page.component.html
  8. 25
      src/Squidex/app/shared/services/backups.service.spec.ts
  9. 65
      src/Squidex/app/shared/services/backups.service.ts
  10. 19
      src/Squidex/app/shared/state/backups.state.spec.ts
  11. 16
      src/Squidex/app/shared/state/backups.state.ts

1
src/Squidex.Web/PermissionExtensions.cs

@ -7,7 +7,6 @@
using Microsoft.AspNetCore.Http;
using Squidex.Infrastructure.Security;
using Squidex.Shared;
using Squidex.Shared.Identity;
namespace Squidex.Web

2
src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs

@ -112,7 +112,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
if (controller.HasPermission(AllPermissions.AppBackupsRead, result.Name, permissions: permissions))
{
result.AddGetLink("backups", controller.Url<BackupsController>(x => nameof(x.GetJobs), values));
result.AddGetLink("backups", controller.Url<BackupsController>(x => nameof(x.GetBackups), values));
}
if (controller.HasPermission(AllPermissions.AppClientsRead, result.Name, permissions: permissions))

7
src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs

@ -12,7 +12,6 @@ using Microsoft.AspNetCore.Mvc;
using Orleans;
using Squidex.Areas.Api.Controllers.Backups.Models;
using Squidex.Domain.Apps.Entities.Backup;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Tasks;
using Squidex.Shared;
@ -44,16 +43,16 @@ namespace Squidex.Areas.Api.Controllers.Backups
/// </returns>
[HttpGet]
[Route("apps/{app}/backups/")]
[ProducesResponseType(typeof(List<BackupJobDto>), 200)]
[ProducesResponseType(typeof(BackupJobsDto), 200)]
[ApiPermission(Permissions.AppBackupsRead)]
[ApiCosts(0)]
public async Task<IActionResult> GetJobs(string app)
public async Task<IActionResult> GetBackups(string app)
{
var backupGrain = grainFactory.GetGrain<IBackupGrain>(AppId);
var jobs = await backupGrain.GetStateAsync();
var response = jobs.Value.ToArray(BackupJobDto.FromBackup);
var response = BackupJobsDto.FromBackups(jobs.Value, this, app);
return Ok(response);
}

22
src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs

@ -9,10 +9,12 @@ using System;
using NodaTime;
using Squidex.Domain.Apps.Entities.Backup;
using Squidex.Infrastructure.Reflection;
using Squidex.Shared;
using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Backups.Models
{
public sealed class BackupJobDto
public sealed class BackupJobDto : Resource
{
/// <summary>
/// The id of the backup job.
@ -44,9 +46,23 @@ namespace Squidex.Areas.Api.Controllers.Backups.Models
/// </summary>
public JobStatus Status { get; set; }
public static BackupJobDto FromBackup(IBackupJob backup)
public static BackupJobDto FromBackup(IBackupJob backup, ApiController controller, string app)
{
return SimpleMapper.Map(backup, new BackupJobDto());
var result = SimpleMapper.Map(backup, new BackupJobDto());
return CreateLinks(result, controller, app);
}
private static BackupJobDto CreateLinks(BackupJobDto result, ApiController controller, string app)
{
var values = new { app, id = result.Id };
if (controller.HasPermission(Permissions.AppBackupsDelete, app))
{
result.AddDeleteLink("delete", controller.Url<BackupsController>(x => nameof(x.DeleteBackup), values));
}
return result;
}
}
}

49
src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobsDto.cs

@ -0,0 +1,49 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Squidex.Domain.Apps.Entities.Backup;
using Squidex.Shared;
using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Backups.Models
{
public sealed class BackupJobsDto : Resource
{
/// <summary>
/// The backups.
/// </summary>
[Required]
public BackupJobDto[] Items { get; set; }
public static BackupJobsDto FromBackups(IEnumerable<IBackupJob> backups, ApiController controller, string app)
{
var result = new BackupJobsDto
{
Items = backups.Select(x => BackupJobDto.FromBackup(x, controller, app)).ToArray()
};
return CreateLinks(result, controller, app);
}
private static BackupJobsDto CreateLinks(BackupJobsDto result, ApiController controller, string app)
{
var values = new { app };
result.AddSelfLink(controller.Url<BackupsController>(x => nameof(x.GetBackups), values));
if (controller.HasPermission(Permissions.AppBackupsCreate, app))
{
result.AddPostLink("create", controller.Url<BackupsController>(x => nameof(x.PostBackup), values));
}
return result;
}
}
}

1
src/Squidex/Areas/Api/Controllers/Rules/Models/RuleEventsDto.cs

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;

5
src/Squidex/app/features/settings/pages/backups/backups-page.component.html

@ -12,7 +12,7 @@
<sqx-shortcut keys="ctrl+shift+r" (trigger)="reload()"></sqx-shortcut>
<button type="button" class="btn btn-success" [disabled]="backupsState.maxBackupsReached | async" (click)="start()">
<button type="button" class="btn btn-success" [disabled]="backupsState.maxBackupsReached | async" *ngIf="backupsState.links | async | sqxHasLink:'create'" (click)="start()">
Start Backup
</button>
</ng-container>
@ -78,7 +78,8 @@
</div>
</div>
<div class="col-auto">
<button type="button" class="btn btn-text-danger"
<button type="button" class="btn btn-text-danger mt-1"
[disabled]="backup | sqxHasNoLink:'delete'"
(sqxConfirmClick)="delete(backup)"
confirmTitle="Delete backup"
confirmText="Do you really want to delete the backup?">

25
src/Squidex/app/shared/services/backups.service.spec.ts

@ -12,8 +12,10 @@ import {
AnalyticsService,
ApiUrlConfig,
BackupDto,
BackupsDto,
BackupsService,
DateTime,
Resource,
RestoreDto
} from '@app/shared/internal';
@ -38,7 +40,7 @@ describe('BackupsService', () => {
it('should make get request to get backups',
inject([BackupsService, HttpTestingController], (backupsService: BackupsService, httpMock: HttpTestingController) => {
let backups: BackupDto[];
let backups: BackupsDto;
backupsService.getBackups('my-app').subscribe(result => {
backups = result;
@ -49,8 +51,8 @@ describe('BackupsService', () => {
expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush([
{
req.flush({
items: [{
id: '1',
started: '2017-02-03',
stopped: '2017-02-04',
@ -65,14 +67,17 @@ describe('BackupsService', () => {
handledEvents: 23,
handledAssets: 27,
status: 'Completed'
}
]);
}]
});
expect(backups!).toEqual(
new BackupsDto(
2,
[
new BackupDto('1', DateTime.parseISO_UTC('2017-02-03'), DateTime.parseISO_UTC('2017-02-04'), 13, 17, 'Failed'),
new BackupDto('2', DateTime.parseISO_UTC('2018-02-03'), null, 23, 27, 'Completed')
]);
]
));
}));
it('should make get request to get restore',
@ -184,7 +189,13 @@ describe('BackupsService', () => {
it('should make delete request to remove language',
inject([BackupsService, HttpTestingController], (backupsService: BackupsService, httpMock: HttpTestingController) => {
backupsService.deleteBackup('my-app', '1').subscribe();
const resource: Resource = {
_links: {
delete: { method: 'DELETE', href: '/api/apps/my-app/backups/1' }
}
};
backupsService.deleteBackup('my-app', resource).subscribe();
const req = httpMock.expectOne('http://service/p/api/apps/my-app/backups/1');

65
src/Squidex/app/shared/services/backups.service.ts

@ -16,10 +16,20 @@ import {
DateTime,
Model,
pretifyError,
Types
Resource,
ResourceLinks,
ResultSet,
Types,
withLinks
} from '@app/framework';
export class BackupsDto extends ResultSet<BackupDto> {
public readonly _links: ResourceLinks = {};
}
export class BackupDto extends Model<BackupDto> {
public readonly _links: ResourceLinks = {};
constructor(
public readonly id: string,
public readonly started: DateTime,
@ -58,21 +68,14 @@ export class BackupsService {
) {
}
public getBackups(appName: string): Observable<BackupDto[]> {
public getBackups(appName: string): Observable<BackupsDto> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/backups`);
return this.http.get<any[]>(url).pipe(
return this.http.get<{ items: any[] } & Resource>(url).pipe(
map(body => {
const backups = body.map(item =>
new BackupDto(
item.id,
DateTime.parseISO_UTC(item.started),
item.stopped ? DateTime.parseISO_UTC(item.stopped) : null,
item.handledEvents,
item.handledAssets,
item.status));
return backups;
const backups = body.items.map(item => parseBackup(item));
return withLinks(new BackupsDto(backups.length, backups), body);
}),
pretifyError('Failed to load backups.'));
}
@ -82,12 +85,7 @@ export class BackupsService {
return this.http.get<any>(url).pipe(
map(body => {
const restore = new RestoreDto(
body.url,
DateTime.parseISO_UTC(body.started),
body.stopped ? DateTime.parseISO_UTC(body.stopped) : null,
body.status,
body.log);
const restore = parseRestore(body);
return restore;
}),
@ -121,13 +119,36 @@ export class BackupsService {
pretifyError('Failed to start restore.'));
}
public deleteBackup(appName: string, id: string): Observable<any> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/backups/${id}`);
public deleteBackup(appName: string, resource: Resource): Observable<any> {
const link = resource._links['delete'];
const url = this.apiUrl.buildUrl(link.href);
return this.http.delete(url).pipe(
return this.http.request(link.method, url).pipe(
tap(() => {
this.analytics.trackEvent('Backup', 'Deleted', appName);
}),
pretifyError('Failed to delete backup.'));
}
}
function parseRestore(response: any) {
return new RestoreDto(
response.url,
DateTime.parseISO_UTC(response.started),
response.stopped ? DateTime.parseISO_UTC(response.stopped) : null,
response.status,
response.log);
}
function parseBackup(response: any) {
return withLinks(
new BackupDto(
response.id,
DateTime.parseISO_UTC(response.started),
response.stopped ? DateTime.parseISO_UTC(response.stopped) : null,
response.handledEvents,
response.handledAssets,
response.status),
response);
}

19
src/Squidex/app/shared/state/backups.state.spec.ts

@ -11,6 +11,7 @@ import { IMock, It, Mock, Times } from 'typemoq';
import {
BackupDto,
BackupsDto,
BackupsService,
BackupsState,
DateTime,
@ -25,10 +26,8 @@ describe('BackupsState', () => {
appsState
} = TestValues;
const oldBackups = [
new BackupDto('id1', DateTime.now(), null, 1, 1, 'Started'),
new BackupDto('id2', DateTime.now(), null, 2, 2, 'Started')
];
const backup1 = new BackupDto('id1', DateTime.now(), null, 1, 1, 'Started');
const backup2 = new BackupDto('id2', DateTime.now(), null, 2, 2, 'Started');
let dialogs: IMock<DialogService>;
let backupsService: IMock<BackupsService>;
@ -48,11 +47,11 @@ describe('BackupsState', () => {
describe('Loading', () => {
it('should load backups', () => {
backupsService.setup(x => x.getBackups(app))
.returns(() => of(oldBackups)).verifiable();
.returns(() => of(new BackupsDto(2, [backup1, backup2]))).verifiable();
backupsState.load().subscribe();
expect(backupsState.snapshot.backups.values).toEqual(oldBackups);
expect(backupsState.snapshot.backups.values).toEqual([backup1, backup2]);
expect(backupsState.snapshot.isLoaded).toBeTruthy();
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never());
@ -60,7 +59,7 @@ describe('BackupsState', () => {
it('should show notification on load when reload is true', () => {
backupsService.setup(x => x.getBackups(app))
.returns(() => of(oldBackups)).verifiable();
.returns(() => of(new BackupsDto(2, [backup1, backup2]))).verifiable();
backupsState.load(true, false).subscribe();
@ -95,7 +94,7 @@ describe('BackupsState', () => {
describe('Updates', () => {
beforeEach(() => {
backupsService.setup(x => x.getBackups(app))
.returns(() => of(oldBackups)).verifiable();
.returns(() => of(new BackupsDto(2, [backup1, backup2]))).verifiable();
backupsState.load().subscribe();
});
@ -112,10 +111,10 @@ describe('BackupsState', () => {
});
it('should not remove backup from snapshot', () => {
backupsService.setup(x => x.deleteBackup(app, oldBackups[0].id))
backupsService.setup(x => x.deleteBackup(app, backup1))
.returns(() => of({})).verifiable();
backupsState.delete(oldBackups[0]).subscribe();
backupsState.delete(backup1).subscribe();
expect(backupsState.snapshot.backups.length).toBe(2);

16
src/Squidex/app/shared/state/backups.state.ts

@ -12,6 +12,7 @@ import { distinctUntilChanged, map, tap } from 'rxjs/operators';
import {
DialogService,
ImmutableArray,
ResourceLinks,
shareSubscribed,
State
} from '@app/framework';
@ -26,6 +27,9 @@ interface Snapshot {
// Indicates if the backups are loaded.
isLoaded?: boolean;
// The links.
links: ResourceLinks;
}
type BackupsList = ImmutableArray<BackupDto>;
@ -44,12 +48,16 @@ export class BackupsState extends State<Snapshot> {
this.changes.pipe(map(x => !!x.isLoaded),
distinctUntilChanged());
public links =
this.changes.pipe(map(x => x.links),
distinctUntilChanged());
constructor(
private readonly appsState: AppsState,
private readonly backupsService: BackupsService,
private readonly dialogs: DialogService
) {
super({ backups: ImmutableArray.empty() });
super({ backups: ImmutableArray.empty(), links: {} });
}
public load(isReload = false, silent = false): Observable<any> {
@ -58,7 +66,7 @@ export class BackupsState extends State<Snapshot> {
}
return this.backupsService.getBackups(this.appName).pipe(
tap(items => {
tap(({ items, _links: links }) => {
if (isReload && !silent) {
this.dialogs.notifyInfo('Backups reloaded.');
}
@ -66,7 +74,7 @@ export class BackupsState extends State<Snapshot> {
this.next(s => {
const backups = ImmutableArray.of(items);
return { ...s, backups, isLoaded: true };
return { ...s, backups, isLoaded: true, links };
});
}),
shareSubscribed(this.dialogs, { silent }));
@ -81,7 +89,7 @@ export class BackupsState extends State<Snapshot> {
}
public delete(backup: BackupDto): Observable<any> {
return this.backupsService.deleteBackup(this.appsState.appName, backup.id).pipe(
return this.backupsService.deleteBackup(this.appsState.appName, backup).pipe(
tap(() => {
this.dialogs.notifyInfo('Backup is about to be deleted.');
}),

Loading…
Cancel
Save