Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 14 additions & 14 deletions ConsoleApp1/AppDbContextConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,19 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
{
public DbSet<TodoItem> Todos => Set<TodoItem>();

protected override void OnModelCreating(ModelBuilder modelBuilder)
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// TodoItem mapping
modelBuilder.Entity<TodoItem>(b =>
{
// TodoItem mapping
modelBuilder.Entity<TodoItem>(b =>
{
b.HasKey(x => x.Id);
b.Property(x => x.Title).IsRequired().HasMaxLength(200);
b.Property(x => x.Description).HasMaxLength(2000);
b.Property(x => x.CreatedAtUtc).HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property(x => x.Priority).HasDefaultValue(TodoPriority.Medium);
b.Property(x => x.LabelsCsv).HasMaxLength(2000);
b.HasQueryFilter(x => !x.IsDeleted);
b.HasIndex(x => new { x.IsCompleted, x.IsDeleted, x.DueAtUtc });
});
}
b.HasKey(x => x.Id);
b.Property(x => x.Title).IsRequired().HasMaxLength(200);
b.Property(x => x.Description).HasMaxLength(2000);
b.Property(x => x.CreatedAtUtc).HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property(x => x.Priority).HasDefaultValue(TodoPriority.Medium);
b.Property(x => x.LabelsCsv).HasMaxLength(2000);
b.HasQueryFilter(x => !x.IsDeleted);
b.HasIndex(x => new { x.IsCompleted, x.IsDeleted, x.DueAtUtc });
});
}
}
2 changes: 1 addition & 1 deletion ConsoleApp1/ConsoleApp1.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.JsonPatch" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.JsonPatch" Version="9.0.9" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.20" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
Expand Down
48 changes: 24 additions & 24 deletions ConsoleApp1/ErrorHandlingMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@ namespace WebApi.Middleware
public class ErrorHandlingMiddleware(RequestDelegate next, ILogger<ErrorHandlingMiddleware> logger)
{
public async Task Invoke(HttpContext context)
{
try
{
try
{
await next(context);
}
catch (ValidationException vex)
{
logger.LogWarning(vex, "Validation failed");
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
context.Response.ContentType = "application/problem+json";
await next(context);
}
catch (ValidationException vex)
{
logger.LogWarning(vex, "Validation failed");
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
context.Response.ContentType = "application/problem+json";

var problem = new ValidationProblemDetails
{
Expand All @@ -29,26 +29,26 @@ public async Task Invoke(HttpContext context)
problem.Errors[kv.Key] = kv.Value;
}

await context.Response.WriteAsync(JsonSerializer.Serialize(problem));
}
catch (Exception ex)
{
logger.LogError(ex, "Unhandled exception");
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsync(JsonSerializer.Serialize(problem));
}
catch (Exception ex)
{
logger.LogError(ex, "Unhandled exception");
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
context.Response.ContentType = "application/problem+json";

var problem = new ProblemDetails
{
Title = "Internal Server Error",
Status = StatusCodes.Status500InternalServerError,
Detail = "An unexpected error occurred. Please try again later."
};
var problem = new ProblemDetails
{
Title = "Internal Server Error",
Status = StatusCodes.Status500InternalServerError,
Detail = "An unexpected error occurred. Please try again later."
};

await context.Response.WriteAsync(JsonSerializer.Serialize(problem));
}
await context.Response.WriteAsync(JsonSerializer.Serialize(problem));
}
}
}
}

namespace WebApi
{
Expand Down
32 changes: 16 additions & 16 deletions ConsoleApp1/TodoDueReminderService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,26 @@ public class TodoDueReminderService(ILogger<TodoDueReminderService> logger, ITod
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var timer = new PeriodicTimer(TimeSpan.FromMinutes(options.CurrentValue.IntervalMinutes <= 0 ? 1 : options.CurrentValue.IntervalMinutes));
try
{
var timer = new PeriodicTimer(TimeSpan.FromMinutes(options.CurrentValue.IntervalMinutes <= 0 ? 1 : options.CurrentValue.IntervalMinutes));
try
while (await timer.WaitForNextTickAsync(stoppingToken))
{
while (await timer.WaitForNextTickAsync(stoppingToken))
var overdue = await service.GetOverdueCountAsync(stoppingToken);
if (overdue > 0)
{
var overdue = await service.GetOverdueCountAsync(stoppingToken);
if (overdue > 0)
{
logger.LogWarning("There are {Overdue} overdue todos.", overdue);
}
else
{
logger.LogInformation("No overdue todos.");
}
logger.LogWarning("There are {Overdue} overdue todos.", overdue);
}
else
{
logger.LogInformation("No overdue todos.");
}
}
catch (OperationCanceledException)
{
// normal on shutdown
}
}
catch (OperationCanceledException)
{
// normal on shutdown
}
}
}
130 changes: 65 additions & 65 deletions ConsoleApp1/TodoEndpointsConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,88 +21,88 @@ public static RouteGroupBuilder MapTodoEndpoints(this IEndpointRouteBuilder app)
.WithTags("Todos");

// List with paging/filtering/sorting: /api/todos?page=1&pageSize=10&search=foo&sortBy=dueAtUtc&sortDir=desc&isCompleted=false
group.MapGet("", async (HttpContext http, AppDbContext db, int page = 1, int pageSize = 20, string? search = null,
string? sortBy = "createdAtUtc", string? sortDir = "desc", bool? isCompleted = null, string? label = null, string? priority = null, CancellationToken ct = default) =>
group.MapGet("", async(HttpContext http, AppDbContext db, int page = 1, int pageSize = 20, string ? search = null,
string ? sortBy = "createdAtUtc", string ? sortDir = "desc", bool ? isCompleted = null, string ? label = null, string ? priority = null, CancellationToken ct = default) =>
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 1, 200);
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 1, 200);

var query = db.Todos.AsNoTracking().AsQueryable();
var query = db.Todos.AsNoTracking().AsQueryable();

if (!string.IsNullOrWhiteSpace(search))
{
var like = $"%{search.Trim()}%";
query = query.Where(t =>
EF.Functions.Like(t.Title, like) ||
(t.Description != null && EF.Functions.Like(t.Description, like)));
}
if (!string.IsNullOrWhiteSpace(search))
{
var like = $"%{search.Trim()}%";
query = query.Where(t =>
EF.Functions.Like(t.Title, like) ||
(t.Description != null && EF.Functions.Like(t.Description, like)));
}

if (isCompleted is not null)
{
query = query.Where(t => t.IsCompleted == isCompleted);
}
if (isCompleted is not null)
{
query = query.Where(t => t.IsCompleted == isCompleted);
}

if (!string.IsNullOrWhiteSpace(label))
{
var l = label.Trim();
query = query.Where(t => t.LabelsCsv != null && EF.Functions.Like(t.LabelsCsv, $"%{l}%"));
}
if (!string.IsNullOrWhiteSpace(label))
{
var l = label.Trim();
query = query.Where(t => t.LabelsCsv != null && EF.Functions.Like(t.LabelsCsv, $"%{l}%"));
}

if (!string.IsNullOrWhiteSpace(priority) && Enum.TryParse<TodoPriority>(priority, true, out var pr))
{
query = query.Where(t => t.Priority == pr);
}
if (!string.IsNullOrWhiteSpace(priority) && Enum.TryParse<TodoPriority>(priority, true, out var pr))
{
query = query.Where(t => t.Priority == pr);
}

// Sorting
query = (sortBy?.ToLowerInvariant(), sortDir?.ToLowerInvariant()) switch
{
("title", "asc") => query.OrderBy(t => t.Title),
("title", "desc") => query.OrderByDescending(t => t.Title),
// Sorting
query = (sortBy?.ToLowerInvariant(), sortDir?.ToLowerInvariant()) switch
{
("title", "asc") => query.OrderBy(t => t.Title),
("title", "desc") => query.OrderByDescending(t => t.Title),

("dueatutc", "asc") => query.OrderBy(t => t.DueAtUtc),
("dueatutc", "desc") => query.OrderByDescending(t => t.DueAtUtc),
("dueatutc", "asc") => query.OrderBy(t => t.DueAtUtc),
("dueatutc", "desc") => query.OrderByDescending(t => t.DueAtUtc),

("priority", "asc") => query.OrderBy(t => t.Priority),
("priority", "desc") => query.OrderByDescending(t => t.Priority),
("priority", "asc") => query.OrderBy(t => t.Priority),
("priority", "desc") => query.OrderByDescending(t => t.Priority),

("createdatutc", "asc") => query.OrderBy(t => t.CreatedAtUtc),
("createdatutc", "desc") => query.OrderByDescending(t => t.CreatedAtUtc),
("createdatutc", "asc") => query.OrderBy(t => t.CreatedAtUtc),
("createdatutc", "desc") => query.OrderByDescending(t => t.CreatedAtUtc),

("updatedatutc", "asc") => query.OrderBy(t => t.UpdatedAtUtc),
("updatedatutc", "desc") => query.OrderByDescending(t => t.UpdatedAtUtc),
("updatedatutc", "asc") => query.OrderBy(t => t.UpdatedAtUtc),
("updatedatutc", "desc") => query.OrderByDescending(t => t.UpdatedAtUtc),

_ => query.OrderByDescending(t => t.CreatedAtUtc)
};
_ => query.OrderByDescending(t => t.CreatedAtUtc)
};

var total = await query.CountAsync(ct);
var items = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(t => new TodoDto(t.Id, t.Title, t.Description, t.IsCompleted, t.DueAtUtc, t.CreatedAtUtc, t.UpdatedAtUtc, ParseLabels(t.LabelsCsv), t.Priority))
.ToListAsync(ct);
var total = await query.CountAsync(ct);
var items = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(t => new TodoDto(t.Id, t.Title, t.Description, t.IsCompleted, t.DueAtUtc, t.CreatedAtUtc, t.UpdatedAtUtc, ParseLabels(t.LabelsCsv), t.Priority))
.ToListAsync(ct);

var fullUrl = http.Request.GetDisplayUrl();
var uri = new Uri(fullUrl);
string BuildPageLink(int p)
var fullUrl = http.Request.GetDisplayUrl();
var uri = new Uri(fullUrl);
string BuildPageLink(int p)
{
var qb = QueryString.Create(new Dictionary<string, string?>
{
var qb = QueryString.Create(new Dictionary<string, string?>
{
["page"] = p.ToString(),
["pageSize"] = pageSize.ToString(),
["search"] = search,
["sortBy"] = sortBy,
["sortDir"] = sortDir,
["isCompleted"] = isCompleted?.ToString()?.ToLowerInvariant()
});
var builder = new UriBuilder(uri) { Query = qb.Value };
return builder.Uri.ToString();
}
["page"] = p.ToString(),
["pageSize"] = pageSize.ToString(),
["search"] = search,
["sortBy"] = sortBy,
["sortDir"] = sortDir,
["isCompleted"] = isCompleted?.ToString()?.ToLowerInvariant()
});
var builder = new UriBuilder(uri) { Query = qb.Value };
return builder.Uri.ToString();
}

string? next = (page * pageSize < total) ? BuildPageLink(page + 1) : null;
string? prev = (page > 1) ? BuildPageLink(page - 1) : null;
string? next = (page * pageSize < total) ? BuildPageLink(page + 1) : null;
string? prev = (page > 1) ? BuildPageLink(page - 1) : null;

return Results.Ok(new PagedResult<TodoDto>(items, total, page, pageSize, next, prev));
})
return Results.Ok(new PagedResult<TodoDto>(items, total, page, pageSize, next, prev));
})
.WithName("ListTodos")
.Produces<PagedResult<TodoDto>>(StatusCodes.Status200OK)
.WithSummary("List todos with paging, filtering, sorting")
Expand Down
8 changes: 4 additions & 4 deletions ConsoleApp1/TodoServiceImplementation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ public interface ITodoService
public class TodoService(AppDbContext db) : ITodoService
{
public Task<int> GetOverdueCountAsync(CancellationToken ct = default)
{
var now = DateTimeOffset.UtcNow;
return db.Todos.CountAsync(t => !t.IsCompleted && t.DueAtUtc != null && t.DueAtUtc < now, ct);
}
{
var now = DateTimeOffset.UtcNow;
return db.Todos.CountAsync(t => !t.IsCompleted && t.DueAtUtc != null && t.DueAtUtc < now, ct);
}
}