mirror of https://github.com/Squidex/squidex.git
52 changed files with 1358 additions and 559 deletions
@ -0,0 +1,103 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Squidex.Infrastructure.UsageTracking |
|||
{ |
|||
public sealed class ApiUsageTracker : IApiUsageTracker |
|||
{ |
|||
public const string CounterTotalBytes = "TotalBytes"; |
|||
public const string CounterTotalWeight = "TotalWeight"; |
|||
public const string CounterTotalCalls = "TotalCalls"; |
|||
public const string CounterTotalElapsedMs = "TotalElapsedMs"; |
|||
private readonly IUsageTracker usageTracker; |
|||
|
|||
public ApiUsageTracker(IUsageTracker usageTracker) |
|||
{ |
|||
this.usageTracker = usageTracker; |
|||
} |
|||
|
|||
public async Task<long> GetMonthlyWeightAsync(string key, DateTime date) |
|||
{ |
|||
var apiKey = GetKey(key); |
|||
|
|||
var counters = await usageTracker.GetForMonthAsync(apiKey, date); |
|||
|
|||
return counters.GetInt64(CounterTotalWeight); |
|||
} |
|||
|
|||
public Task TrackAsync(DateTime date, string key, string? category, double weight, long elapsed, long bytes) |
|||
{ |
|||
var apiKey = GetKey(key); |
|||
|
|||
var counters = new Counters |
|||
{ |
|||
[CounterTotalWeight] = weight, |
|||
[CounterTotalCalls] = 1, |
|||
[CounterTotalElapsedMs] = elapsed, |
|||
[CounterTotalBytes] = bytes |
|||
}; |
|||
|
|||
return usageTracker.TrackAsync(date, apiKey, category, counters); |
|||
} |
|||
|
|||
public async Task<(ApiStats Summary, Dictionary<string, List<(DateTime Date, ApiStats Stats)>> Details)> QueryAsync(string key, DateTime fromDate, DateTime toDate) |
|||
{ |
|||
var apiKey = GetKey(key); |
|||
|
|||
var queries = await usageTracker.QueryAsync(apiKey, fromDate, toDate); |
|||
|
|||
var result = new Dictionary<string, List<(DateTime Date, ApiStats Stats)>>(); |
|||
|
|||
var summaryBytes = 0L; |
|||
var summaryCalls = 0L; |
|||
var summaryElapsed = 0L; |
|||
|
|||
foreach (var (category, usages) in queries) |
|||
{ |
|||
var resultByCategory = new List<(DateTime Date, ApiStats)>(); |
|||
|
|||
foreach (var usage in usages) |
|||
{ |
|||
var dateBytes = usage.Counters.GetInt64(CounterTotalBytes); |
|||
var dateCalls = usage.Counters.GetInt64(CounterTotalCalls); |
|||
var dateElapsed = usage.Counters.GetInt64(CounterTotalElapsedMs); |
|||
var dateElapsedAvg = CalculateAverage(dateCalls, dateElapsed); |
|||
|
|||
resultByCategory.Add((usage.Date, new ApiStats(dateCalls, dateElapsedAvg, dateBytes))); |
|||
|
|||
summaryBytes += dateBytes; |
|||
summaryCalls += dateCalls; |
|||
summaryElapsed += dateElapsed; |
|||
} |
|||
|
|||
result[category] = resultByCategory; |
|||
} |
|||
|
|||
var summaryElapsedAvg = CalculateAverage(summaryCalls, summaryElapsed); |
|||
|
|||
var summary = new ApiStats(summaryCalls, summaryElapsedAvg, summaryBytes); |
|||
|
|||
return (summary, result); |
|||
} |
|||
|
|||
private static double CalculateAverage(long calls, long elapsed) |
|||
{ |
|||
return calls > 0 ? Math.Round((double)elapsed / calls, 2) : 0; |
|||
} |
|||
|
|||
private static string GetKey(string key) |
|||
{ |
|||
Guard.NotNullOrEmpty(key); |
|||
|
|||
return $"{key}_API"; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,22 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Squidex.Infrastructure.UsageTracking |
|||
{ |
|||
public interface IApiUsageTracker |
|||
{ |
|||
Task TrackAsync(DateTime date, string key, string? category, double weight, long elapsed, long bytes); |
|||
|
|||
Task<long> GetMonthlyWeightAsync(string key, DateTime date); |
|||
|
|||
Task<(ApiStats Summary, Dictionary<string, List<(DateTime Date, ApiStats Stats)>> Details)> QueryAsync(string key, DateTime fromDate, DateTime toDate); |
|||
} |
|||
} |
|||
@ -1,29 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
#pragma warning disable SA1401 // Fields must be private
|
|||
|
|||
namespace Squidex.Infrastructure.UsageTracking |
|||
{ |
|||
public sealed class Usage |
|||
{ |
|||
public readonly double Count; |
|||
public readonly double ElapsedMs; |
|||
|
|||
public Usage(double elapsed, double count) |
|||
{ |
|||
ElapsedMs = elapsed; |
|||
|
|||
Count = count; |
|||
} |
|||
|
|||
public Usage Add(double elapsed, double weight) |
|||
{ |
|||
return new Usage(ElapsedMs + elapsed, Count + weight); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using Squidex.Infrastructure; |
|||
|
|||
namespace Squidex.Web |
|||
{ |
|||
public interface IAppFeature |
|||
{ |
|||
NamedId<Guid> AppId { get; } |
|||
} |
|||
} |
|||
@ -0,0 +1,97 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Http; |
|||
using Microsoft.AspNetCore.Http.Features; |
|||
using NodaTime; |
|||
using Squidex.Domain.Apps.Entities.Apps; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Security; |
|||
using Squidex.Infrastructure.UsageTracking; |
|||
|
|||
namespace Squidex.Web.Pipeline |
|||
{ |
|||
public sealed class UsageMiddleware : IMiddleware |
|||
{ |
|||
private readonly IAppLogStore log; |
|||
private readonly IApiUsageTracker usageTracker; |
|||
private readonly IClock clock; |
|||
|
|||
public UsageMiddleware(IAppLogStore log, IApiUsageTracker usageTracker, IClock clock) |
|||
{ |
|||
Guard.NotNull(log); |
|||
Guard.NotNull(usageTracker); |
|||
Guard.NotNull(clock); |
|||
|
|||
this.log = log; |
|||
|
|||
this.usageTracker = usageTracker; |
|||
|
|||
this.clock = clock; |
|||
} |
|||
|
|||
public async Task InvokeAsync(HttpContext context, RequestDelegate next) |
|||
{ |
|||
var usageBody = SetUsageBody(context); |
|||
|
|||
var watch = ValueStopwatch.StartNew(); |
|||
|
|||
try |
|||
{ |
|||
await next(context); |
|||
} |
|||
finally |
|||
{ |
|||
if (context.Response.StatusCode != StatusCodes.Status429TooManyRequests) |
|||
{ |
|||
var app = context.Features.Get<IAppFeature>()?.AppId; |
|||
|
|||
var costs = context.Features.Get<IApiCostsFeature>()?.Weight ?? 0; |
|||
|
|||
if (app != null) |
|||
{ |
|||
var elapsedMs = watch.Stop(); |
|||
|
|||
var now = clock.GetCurrentInstant(); |
|||
|
|||
var userId = context.User.OpenIdSubject(); |
|||
var userClient = context.User.OpenIdClientId(); |
|||
|
|||
await log.LogAsync(app.Id, now, |
|||
context.Request.Method, |
|||
context.Request.Path, |
|||
userId, |
|||
userClient, |
|||
elapsedMs, |
|||
costs); |
|||
|
|||
if (costs > 0) |
|||
{ |
|||
var bytes = usageBody.BytesWritten + (context.Request.ContentLength ?? 0); |
|||
|
|||
var date = now.ToDateTimeUtc().Date; |
|||
|
|||
await usageTracker.TrackAsync(date, app.Id.ToString(), userClient, costs, elapsedMs, bytes); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
private static UsageResponseBodyFeature SetUsageBody(HttpContext context) |
|||
{ |
|||
var originalBodyFeature = context.Features.Get<IHttpResponseBodyFeature>(); |
|||
|
|||
var usageBody = new UsageResponseBodyFeature(originalBodyFeature); |
|||
|
|||
context.Features.Set<IHttpResponseBodyFeature>(usageBody); |
|||
|
|||
return usageBody; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,62 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.IO.Pipelines; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Squidex.Web.Pipeline |
|||
{ |
|||
public sealed class UsagePipeWriter : PipeWriter |
|||
{ |
|||
private readonly PipeWriter inner; |
|||
private long bytesWritten; |
|||
|
|||
public long BytesWritten |
|||
{ |
|||
get { return bytesWritten; } |
|||
} |
|||
|
|||
public UsagePipeWriter(PipeWriter inner) |
|||
{ |
|||
this.inner = inner; |
|||
} |
|||
|
|||
public override void Advance(int bytes) |
|||
{ |
|||
inner.Advance(bytes); |
|||
|
|||
bytesWritten += bytes; |
|||
} |
|||
|
|||
public override void CancelPendingFlush() |
|||
{ |
|||
inner.CancelPendingFlush(); |
|||
} |
|||
|
|||
public override void Complete(Exception? exception = null) |
|||
{ |
|||
inner.Complete(); |
|||
} |
|||
|
|||
public override ValueTask<FlushResult> FlushAsync(CancellationToken cancellationToken = default) |
|||
{ |
|||
return inner.FlushAsync(cancellationToken); |
|||
} |
|||
|
|||
public override Memory<byte> GetMemory(int sizeHint = 0) |
|||
{ |
|||
return inner.GetMemory(sizeHint); |
|||
} |
|||
|
|||
public override Span<byte> GetSpan(int sizeHint = 0) |
|||
{ |
|||
return inner.GetSpan(sizeHint); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,80 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.IO; |
|||
using System.IO.Pipelines; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Http.Features; |
|||
|
|||
namespace Squidex.Web.Pipeline |
|||
{ |
|||
internal sealed class UsageResponseBodyFeature : IHttpResponseBodyFeature |
|||
{ |
|||
private readonly IHttpResponseBodyFeature inner; |
|||
private readonly UsageStream usageStream; |
|||
private readonly UsagePipeWriter usageWriter; |
|||
private long bytesWritten; |
|||
|
|||
public long BytesWritten |
|||
{ |
|||
get { return bytesWritten + usageStream.BytesWritten + usageWriter.BytesWritten; } |
|||
} |
|||
|
|||
public Stream Stream |
|||
{ |
|||
get { return usageStream; } |
|||
} |
|||
|
|||
public PipeWriter Writer |
|||
{ |
|||
get { return usageWriter; } |
|||
} |
|||
|
|||
public UsageResponseBodyFeature(IHttpResponseBodyFeature inner) |
|||
{ |
|||
usageStream = new UsageStream(inner.Stream); |
|||
usageWriter = new UsagePipeWriter(inner.Writer); |
|||
|
|||
this.inner = inner; |
|||
} |
|||
|
|||
public Task StartAsync(CancellationToken cancellationToken = default) |
|||
{ |
|||
return inner.StartAsync(cancellationToken); |
|||
} |
|||
|
|||
public Task CompleteAsync() |
|||
{ |
|||
return inner.CompleteAsync(); |
|||
} |
|||
|
|||
public void DisableBuffering() |
|||
{ |
|||
inner.DisableBuffering(); |
|||
} |
|||
|
|||
public async Task SendFileAsync(string path, long offset, long? count, CancellationToken cancellationToken = default) |
|||
{ |
|||
await inner.SendFileAsync(path, offset, count, cancellationToken); |
|||
|
|||
if (count != null) |
|||
{ |
|||
bytesWritten += count.Value; |
|||
} |
|||
else |
|||
{ |
|||
var file = new FileInfo(path); |
|||
|
|||
if (file.Exists) |
|||
{ |
|||
bytesWritten += file.Length; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,130 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.IO; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Squidex.Web.Pipeline |
|||
{ |
|||
internal sealed class UsageStream : Stream |
|||
{ |
|||
private readonly Stream inner; |
|||
private long bytesWritten; |
|||
|
|||
public long BytesWritten |
|||
{ |
|||
get { return bytesWritten; } |
|||
} |
|||
|
|||
public override bool CanRead |
|||
{ |
|||
get { return false; } |
|||
} |
|||
|
|||
public override bool CanSeek |
|||
{ |
|||
get { return false; } |
|||
} |
|||
|
|||
public override bool CanWrite |
|||
{ |
|||
get { return inner.CanWrite; } |
|||
} |
|||
|
|||
public override long Length |
|||
{ |
|||
get { throw new NotSupportedException(); } |
|||
} |
|||
|
|||
public override long Position |
|||
{ |
|||
get => throw new NotSupportedException(); |
|||
set => throw new NotSupportedException(); |
|||
} |
|||
|
|||
public UsageStream(Stream inner) |
|||
{ |
|||
this.inner = inner; |
|||
} |
|||
|
|||
public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback callback, object? state) |
|||
{ |
|||
var result = inner.BeginWrite(buffer, offset, count, callback, state); |
|||
|
|||
bytesWritten += count; |
|||
|
|||
return result; |
|||
} |
|||
|
|||
public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) |
|||
{ |
|||
await base.WriteAsync(buffer, offset, count, cancellationToken); |
|||
|
|||
bytesWritten += count; |
|||
} |
|||
|
|||
public override void Write(byte[] buffer, int offset, int count) |
|||
{ |
|||
inner.Write(buffer, offset, count); |
|||
|
|||
bytesWritten += count; |
|||
} |
|||
|
|||
public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default) |
|||
{ |
|||
await base.WriteAsync(buffer, cancellationToken); |
|||
|
|||
bytesWritten += buffer.Length; |
|||
} |
|||
|
|||
public override void Write(ReadOnlySpan<byte> buffer) |
|||
{ |
|||
inner.Write(buffer); |
|||
|
|||
bytesWritten += buffer.Length; |
|||
} |
|||
|
|||
public override void WriteByte(byte value) |
|||
{ |
|||
inner.WriteByte(value); |
|||
|
|||
bytesWritten++; |
|||
} |
|||
|
|||
public override Task FlushAsync(CancellationToken cancellationToken) |
|||
{ |
|||
return inner.FlushAsync(cancellationToken); |
|||
} |
|||
|
|||
public override void Flush() |
|||
{ |
|||
inner.Flush(); |
|||
} |
|||
|
|||
public override void EndWrite(IAsyncResult asyncResult) |
|||
{ |
|||
inner.EndWrite(asyncResult); |
|||
} |
|||
|
|||
public override void SetLength(long value) |
|||
{ |
|||
throw new NotSupportedException(); |
|||
} |
|||
|
|||
public override int Read(byte[] buffer, int offset, int count) |
|||
{ |
|||
throw new NotSupportedException(); |
|||
} |
|||
|
|||
public override long Seek(long offset, SeekOrigin origin) |
|||
{ |
|||
throw new NotSupportedException(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,56 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.ComponentModel.DataAnnotations; |
|||
using System.Linq; |
|||
using Squidex.Infrastructure.UsageTracking; |
|||
|
|||
namespace Squidex.Areas.Api.Controllers.Statistics.Models |
|||
{ |
|||
public sealed class ApiUsagesDto |
|||
{ |
|||
/// <summary>
|
|||
/// The total number of API calls.
|
|||
/// </summary>
|
|||
public long TotalCalls { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// The total number of bytes transferred.
|
|||
/// </summary>
|
|||
public long TotalBytes { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// The allowed API calls.
|
|||
/// </summary>
|
|||
public long AllowedCalls { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// The average duration in milliseconds.
|
|||
/// </summary>
|
|||
public double AverageMs { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// The statistics by date and group.
|
|||
/// </summary>
|
|||
[Required] |
|||
public Dictionary<string, ApiUsageDto[]> Details { get; set; } |
|||
|
|||
public static ApiUsagesDto FromUsages(long allowedCalls, ApiStats summary, Dictionary<string, List<(DateTime Date, ApiStats Stats)>> details) |
|||
{ |
|||
return new ApiUsagesDto |
|||
{ |
|||
AllowedCalls = allowedCalls, |
|||
AverageMs = summary.AverageElapsed, |
|||
TotalBytes = summary.TotalBytes, |
|||
TotalCalls = summary.TotalBytes, |
|||
Details = details.ToDictionary(x => x.Key, x => x.Value.Select(ApiUsageDto.FromUsage).ToArray()) |
|||
}; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,131 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Threading.Tasks; |
|||
using FakeItEasy; |
|||
using FluentAssertions; |
|||
using Xunit; |
|||
|
|||
namespace Squidex.Infrastructure.UsageTracking |
|||
{ |
|||
public class ApiUsageTrackerTests |
|||
{ |
|||
private readonly IUsageTracker usageTracker = A.Fake<IUsageTracker>(); |
|||
private readonly string key = Guid.NewGuid().ToString(); |
|||
private readonly DateTime date = DateTime.Today; |
|||
private readonly ApiUsageTracker sut; |
|||
|
|||
public ApiUsageTrackerTests() |
|||
{ |
|||
sut = new ApiUsageTracker(usageTracker); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_track_usage() |
|||
{ |
|||
Counters? measuredCounters = null; |
|||
|
|||
A.CallTo(() => usageTracker.TrackAsync(date, $"{key}_API", null, A<Counters>.Ignored)) |
|||
.Invokes(args => |
|||
{ |
|||
measuredCounters = args.GetArgument<Counters>(3)!; |
|||
}); |
|||
|
|||
await sut.TrackAsync(date, key, null, 4, 120, 1024); |
|||
|
|||
measuredCounters.Should().BeEquivalentTo(new Counters |
|||
{ |
|||
[ApiUsageTracker.CounterTotalBytes] = 1024, |
|||
[ApiUsageTracker.CounterTotalCalls] = 1, |
|||
[ApiUsageTracker.CounterTotalElapsedMs] = 120, |
|||
[ApiUsageTracker.CounterTotalWeight] = 4 |
|||
}); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_query_weight_from_tracker() |
|||
{ |
|||
var counters = new Counters |
|||
{ |
|||
[ApiUsageTracker.CounterTotalWeight] = 4 |
|||
}; |
|||
|
|||
A.CallTo(() => usageTracker.GetForMonthAsync($"{key}_API", date)) |
|||
.Returns(counters); |
|||
|
|||
var result = await sut.GetMonthlyWeightAsync(key, date); |
|||
|
|||
Assert.Equal(4, result); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_query_stats_from_tracker() |
|||
{ |
|||
var dateFrom = date; |
|||
var dateTo = dateFrom.AddDays(4); |
|||
|
|||
var counters = new Dictionary<string, List<(DateTime Date, Counters Counters)>> |
|||
{ |
|||
["my-category"] = new List<(DateTime Date, Counters Counters)> |
|||
{ |
|||
(dateFrom.AddDays(0), Counters(0, 0, 0)), |
|||
(dateFrom.AddDays(1), Counters(4, 100, 2048)), |
|||
(dateFrom.AddDays(2), Counters(0, 0, 0)), |
|||
(dateFrom.AddDays(3), Counters(2, 60, 1024)), |
|||
(dateFrom.AddDays(4), Counters(3, 30, 512)) |
|||
}, |
|||
["*"] = new List<(DateTime Date, Counters Counters)> |
|||
{ |
|||
(dateFrom.AddDays(0), Counters(1, 20, 128)), |
|||
(dateFrom.AddDays(1), Counters(0, 0, 0)), |
|||
(dateFrom.AddDays(2), Counters(5, 90, 16)), |
|||
(dateFrom.AddDays(3), Counters(0, 0, 0)), |
|||
(dateFrom.AddDays(4), Counters(0, 0, 0)) |
|||
} |
|||
}; |
|||
|
|||
A.CallTo(() => usageTracker.QueryAsync($"{key}_API", dateFrom, dateTo)) |
|||
.Returns(counters); |
|||
|
|||
var (summary, stats) = await sut.QueryAsync(key, dateFrom, dateTo); |
|||
|
|||
stats.Should().BeEquivalentTo(new Dictionary<string, List<(DateTime Date, ApiStats Counters)>> |
|||
{ |
|||
["my-category"] = new List<(DateTime Date, ApiStats Counters)> |
|||
{ |
|||
(dateFrom.AddDays(0), new ApiStats(0, 0, 0)), |
|||
(dateFrom.AddDays(1), new ApiStats(4, 25, 2048)), |
|||
(dateFrom.AddDays(2), new ApiStats(0, 0, 0)), |
|||
(dateFrom.AddDays(3), new ApiStats(2, 30, 1024)), |
|||
(dateFrom.AddDays(4), new ApiStats(3, 10, 512)) |
|||
}, |
|||
["*"] = new List<(DateTime Date, ApiStats)> |
|||
{ |
|||
(dateFrom.AddDays(0), new ApiStats(1, 20, 128)), |
|||
(dateFrom.AddDays(1), new ApiStats(0, 0, 0)), |
|||
(dateFrom.AddDays(2), new ApiStats(5, 18, 16)), |
|||
(dateFrom.AddDays(3), new ApiStats(0, 0, 0)), |
|||
(dateFrom.AddDays(4), new ApiStats(0, 0, 0)) |
|||
} |
|||
}); |
|||
|
|||
summary.Should().BeEquivalentTo(new ApiStats(15, 20, 3728)); |
|||
} |
|||
|
|||
private static Counters Counters(long calls, long elapsed, long bytes) |
|||
{ |
|||
return new Counters |
|||
{ |
|||
[ApiUsageTracker.CounterTotalBytes] = bytes, |
|||
[ApiUsageTracker.CounterTotalCalls] = calls, |
|||
[ApiUsageTracker.CounterTotalElapsedMs] = elapsed |
|||
}; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,163 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Threading.Tasks; |
|||
using FakeItEasy; |
|||
using Microsoft.AspNetCore.Http; |
|||
using NodaTime; |
|||
using Squidex.Domain.Apps.Entities.Apps; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.UsageTracking; |
|||
using Xunit; |
|||
|
|||
namespace Squidex.Web.Pipeline |
|||
{ |
|||
public class UsageMiddlewareTests |
|||
{ |
|||
private readonly IAppLogStore appLogStore = A.Fake<IAppLogStore>(); |
|||
private readonly IApiUsageTracker usageTracker = A.Fake<IApiUsageTracker>(); |
|||
private readonly IClock clock = A.Fake<IClock>(); |
|||
private readonly Instant instant = SystemClock.Instance.GetCurrentInstant(); |
|||
private readonly HttpContext httpContext = new DefaultHttpContext(); |
|||
private readonly NamedId<Guid> appId = NamedId.Of(Guid.NewGuid(), "my-app"); |
|||
private readonly RequestDelegate next; |
|||
private readonly UsageMiddleware sut; |
|||
private bool isNextCalled; |
|||
|
|||
public UsageMiddlewareTests() |
|||
{ |
|||
A.CallTo(() => clock.GetCurrentInstant()) |
|||
.Returns(instant); |
|||
|
|||
next = x => |
|||
{ |
|||
isNextCalled = true; |
|||
|
|||
return Task.CompletedTask; |
|||
}; |
|||
|
|||
sut = new UsageMiddleware(appLogStore, usageTracker, clock); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_not_track_if_app_not_defined() |
|||
{ |
|||
await sut.InvokeAsync(httpContext, next); |
|||
|
|||
Assert.True(isNextCalled); |
|||
|
|||
var date = instant.ToDateTimeUtc().Date; |
|||
|
|||
A.CallTo(() => usageTracker.TrackAsync(date, A<string>._, A<string>._, A<double>._, A<long>._, A<long>._)) |
|||
.MustNotHaveHappened(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_not_track_if_call_blocked() |
|||
{ |
|||
httpContext.Features.Set<IAppFeature>(new AppFeature(appId)); |
|||
httpContext.Features.Set<IApiCostsFeature>(new ApiCostsAttribute(13)); |
|||
|
|||
httpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; |
|||
|
|||
await sut.InvokeAsync(httpContext, next); |
|||
|
|||
Assert.True(isNextCalled); |
|||
|
|||
var date = instant.ToDateTimeUtc().Date; |
|||
|
|||
A.CallTo(() => usageTracker.TrackAsync(date, A<string>._, A<string>._, A<double>._, A<long>._, A<long>._)) |
|||
.MustNotHaveHappened(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_track_if_calls_left() |
|||
{ |
|||
httpContext.Features.Set<IAppFeature>(new AppFeature(appId)); |
|||
httpContext.Features.Set<IApiCostsFeature>(new ApiCostsAttribute(13)); |
|||
|
|||
await sut.InvokeAsync(httpContext, next); |
|||
|
|||
Assert.True(isNextCalled); |
|||
|
|||
var date = instant.ToDateTimeUtc().Date; |
|||
|
|||
A.CallTo(() => usageTracker.TrackAsync(date, A<string>._, A<string>._, 13, A<long>._, A<long>._)) |
|||
.MustHaveHappened(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_track_request_bytes() |
|||
{ |
|||
httpContext.Features.Set<IAppFeature>(new AppFeature(appId)); |
|||
httpContext.Features.Set<IApiCostsFeature>(new ApiCostsAttribute(13)); |
|||
httpContext.Request.ContentLength = 1024; |
|||
|
|||
await sut.InvokeAsync(httpContext, next); |
|||
|
|||
Assert.True(isNextCalled); |
|||
|
|||
var date = instant.ToDateTimeUtc().Date; |
|||
|
|||
A.CallTo(() => usageTracker.TrackAsync(date, A<string>._, A<string>._, 13, A<long>._, 1024)) |
|||
.MustHaveHappened(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_track_response_bytes() |
|||
{ |
|||
httpContext.Features.Set<IAppFeature>(new AppFeature(appId)); |
|||
httpContext.Features.Set<IApiCostsFeature>(new ApiCostsAttribute(13)); |
|||
|
|||
await sut.InvokeAsync(httpContext, async x => |
|||
{ |
|||
await x.Response.WriteAsync("Hello World"); |
|||
|
|||
await next(x); |
|||
}); |
|||
|
|||
Assert.True(isNextCalled); |
|||
|
|||
var date = instant.ToDateTimeUtc().Date; |
|||
|
|||
A.CallTo(() => usageTracker.TrackAsync(date, A<string>._, A<string>._, 13, A<long>._, 11)) |
|||
.MustHaveHappened(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_not_track_if_weight_is_zero() |
|||
{ |
|||
httpContext.Features.Set<IAppFeature>(new AppFeature(appId)); |
|||
httpContext.Features.Set<IApiCostsFeature>(new ApiCostsAttribute(0)); |
|||
|
|||
await sut.InvokeAsync(httpContext, next); |
|||
|
|||
Assert.True(isNextCalled); |
|||
|
|||
var date = instant.ToDateTimeUtc().Date; |
|||
|
|||
A.CallTo(() => usageTracker.TrackAsync(date, A<string>._, A<string>._, A<double>._, A<long>._, A<long>._)) |
|||
.MustNotHaveHappened(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_log_request_even_if_weight_is_zero() |
|||
{ |
|||
httpContext.Features.Set<IAppFeature>(new AppFeature(appId)); |
|||
httpContext.Features.Set<IApiCostsFeature>(new ApiCostsAttribute(0)); |
|||
|
|||
httpContext.Request.Method = "GET"; |
|||
httpContext.Request.Path = "/my-path"; |
|||
|
|||
await sut.InvokeAsync(httpContext, next); |
|||
|
|||
A.CallTo(() => appLogStore.LogAsync(appId.Id, instant, "GET", "/my-path", null, null, A<long>._, 0)) |
|||
.MustHaveHappened(); |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue