Refactor API endpoints into modules

This commit is contained in:
2026-01-30 23:59:53 +01:00
parent ba9ba119c2
commit e35f45dc4d
7 changed files with 468 additions and 290 deletions

View File

@@ -0,0 +1,84 @@
using ASTRAIN.Api.Data;
using ASTRAIN.Api.Services;
using ASTRAIN.Shared.Dtos;
using ASTRAIN.Shared.Models;
using ASTRAIN.Shared.Requests;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
namespace ASTRAIN.Api.Endpoints;
/// <summary>
/// Provides exercise-related endpoints.
/// </summary>
internal static class ExerciseEndpoints
{
/// <summary>
/// Registers exercise routes under the provided route group.
/// </summary>
/// <param name="group">The API route group.</param>
/// <returns>The same route group for chaining.</returns>
public static RouteGroupBuilder MapExerciseEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/users/{userId}/exercises", async (string userId, AppDbContext db) =>
{
var user = await UserProvisioning.EnsureUserAsync(db, userId);
var items = await db.Exercises
.Where(e => e.UserId == user.Id)
.OrderBy(e => e.Name)
.Select(e => new ExerciseDto(e.Id, e.Name))
.ToListAsync();
return Results.Ok(items);
})
.WithSummary("List exercises")
.WithDescription("Returns the exercises for the specified user.");
group.MapPost("/users/{userId}/exercises", async (string userId, ExerciseUpsertRequest request, AppDbContext db) =>
{
var user = await UserProvisioning.EnsureUserAsync(db, userId);
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest("Name is required.");
}
var exercise = new Exercise
{
Name = request.Name.Trim(),
UserId = user.Id
};
db.Exercises.Add(exercise);
await db.SaveChangesAsync();
return Results.Ok(new ExerciseDto(exercise.Id, exercise.Name));
})
.WithSummary("Create exercise")
.WithDescription("Creates a new exercise for the specified user.");
group.MapPut("/users/{userId}/exercises/{exerciseId:int}", async (string userId, int exerciseId, ExerciseUpsertRequest request, AppDbContext db) =>
{
var user = await UserProvisioning.EnsureUserAsync(db, userId);
var exercise = await db.Exercises.FirstOrDefaultAsync(e => e.Id == exerciseId && e.UserId == user.Id);
if (exercise is null)
{
return Results.NotFound();
}
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest("Name is required.");
}
exercise.Name = request.Name.Trim();
await db.SaveChangesAsync();
return Results.Ok(new ExerciseDto(exercise.Id, exercise.Name));
})
.WithSummary("Update exercise")
.WithDescription("Updates the name of an exercise for the specified user.");
return group;
}
}

View File

@@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace ASTRAIN.Api.Endpoints;
/// <summary>
/// Provides health check endpoints.
/// </summary>
internal static class HealthEndpoints
{
/// <summary>
/// Registers health check routes under the provided route group.
/// </summary>
/// <param name="group">The API route group.</param>
/// <returns>The same route group for chaining.</returns>
public static RouteGroupBuilder MapHealthEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/health", () => Results.Ok(new { status = "ok" }))
.WithSummary("Health check")
.WithDescription("Returns a simple payload used for liveness checks.");
return group;
}
}

View File

@@ -0,0 +1,165 @@
using ASTRAIN.Api.Data;
using ASTRAIN.Api.Services;
using ASTRAIN.Shared.Dtos;
using ASTRAIN.Shared.Models;
using ASTRAIN.Shared.Requests;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
namespace ASTRAIN.Api.Endpoints;
/// <summary>
/// Provides routine-related endpoints.
/// </summary>
internal static class RoutineEndpoints
{
/// <summary>
/// Registers routine routes under the provided route group.
/// </summary>
/// <param name="group">The API route group.</param>
/// <returns>The same route group for chaining.</returns>
public static RouteGroupBuilder MapRoutineEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/users/{userId}/routines", async (string userId, AppDbContext db) =>
{
var user = await UserProvisioning.EnsureUserAsync(db, userId);
var routines = await db.Routines
.Include(r => r.Exercises)
.ThenInclude(re => re.Exercise)
.Where(r => r.UserId == user.Id)
.OrderBy(r => r.Name)
.ToListAsync();
var payload = routines.Select(r => new RoutineDto(
r.Id,
r.Name,
r.Exercises
.OrderBy(re => re.Order)
.Select(re => new RoutineExerciseDto(re.ExerciseId, re.Exercise?.Name ?? string.Empty, re.Order))
.ToList()
));
return Results.Ok(payload);
})
.WithSummary("List routines")
.WithDescription("Returns all routines for the specified user.");
group.MapGet("/users/{userId}/routines/{routineId:int}", async (string userId, int routineId, AppDbContext db) =>
{
var user = await UserProvisioning.EnsureUserAsync(db, userId);
var routine = await db.Routines
.Include(r => r.Exercises)
.ThenInclude(re => re.Exercise)
.FirstOrDefaultAsync(r => r.Id == routineId && r.UserId == user.Id);
if (routine is null)
{
return Results.NotFound();
}
var payload = new RoutineDto(
routine.Id,
routine.Name,
routine.Exercises
.OrderBy(re => re.Order)
.Select(re => new RoutineExerciseDto(re.ExerciseId, re.Exercise?.Name ?? string.Empty, re.Order))
.ToList());
return Results.Ok(payload);
})
.WithSummary("Get routine")
.WithDescription("Returns a specific routine and its exercises.");
group.MapPost("/users/{userId}/routines", async (string userId, RoutineUpsertRequest request, AppDbContext db) =>
{
var user = await UserProvisioning.EnsureUserAsync(db, userId);
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest("Name is required.");
}
var routine = new Routine
{
Name = request.Name.Trim(),
UserId = user.Id
};
var exercises = await db.Exercises
.Where(e => e.UserId == user.Id && request.ExerciseIds.Contains(e.Id))
.ToListAsync();
routine.Exercises = request.ExerciseIds
.Select((exerciseId, index) => new RoutineExercise
{
ExerciseId = exerciseId,
Order = index
})
.ToList();
db.Routines.Add(routine);
await db.SaveChangesAsync();
var dto = new RoutineDto(
routine.Id,
routine.Name,
routine.Exercises
.Select((re, index) => new RoutineExerciseDto(re.ExerciseId, exercises.FirstOrDefault(e => e.Id == re.ExerciseId)?.Name ?? string.Empty, index))
.ToList());
return Results.Ok(dto);
})
.WithSummary("Create routine")
.WithDescription("Creates a routine and associates exercises with it.");
group.MapPut("/users/{userId}/routines/{routineId:int}", async (string userId, int routineId, RoutineUpsertRequest request, AppDbContext db) =>
{
var user = await UserProvisioning.EnsureUserAsync(db, userId);
var routine = await db.Routines
.Include(r => r.Exercises)
.FirstOrDefaultAsync(r => r.Id == routineId && r.UserId == user.Id);
if (routine is null)
{
return Results.NotFound();
}
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest("Name is required.");
}
routine.Name = request.Name.Trim();
routine.Exercises.Clear();
foreach (var exerciseId in request.ExerciseIds)
{
routine.Exercises.Add(new RoutineExercise
{
ExerciseId = exerciseId,
Order = routine.Exercises.Count
});
}
await db.SaveChangesAsync();
var exercises = await db.Exercises
.Where(e => e.UserId == user.Id && request.ExerciseIds.Contains(e.Id))
.ToListAsync();
var dto = new RoutineDto(
routine.Id,
routine.Name,
routine.Exercises
.OrderBy(re => re.Order)
.Select(re => new RoutineExerciseDto(re.ExerciseId, exercises.FirstOrDefault(e => e.Id == re.ExerciseId)?.Name ?? string.Empty, re.Order))
.ToList());
return Results.Ok(dto);
})
.WithSummary("Update routine")
.WithDescription("Updates routine metadata and exercise ordering.");
return group;
}
}

View File

@@ -0,0 +1,82 @@
using ASTRAIN.Api.Data;
using ASTRAIN.Api.Services;
using ASTRAIN.Shared.Dtos;
using ASTRAIN.Shared.Models;
using ASTRAIN.Shared.Requests;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
namespace ASTRAIN.Api.Endpoints;
/// <summary>
/// Provides routine run-related endpoints.
/// </summary>
internal static class RoutineRunEndpoints
{
/// <summary>
/// Registers routine run routes under the provided route group.
/// </summary>
/// <param name="group">The API route group.</param>
/// <returns>The same route group for chaining.</returns>
public static RouteGroupBuilder MapRoutineRunEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/users/{userId}/routines/{routineId:int}/last-run", async (string userId, int routineId, AppDbContext db) =>
{
var user = await UserProvisioning.EnsureUserAsync(db, userId);
var lastRun = await db.RoutineRuns
.Include(rr => rr.Entries)
.Where(rr => rr.UserId == user.Id && rr.RoutineId == routineId)
.OrderByDescending(rr => rr.PerformedAt)
.FirstOrDefaultAsync();
if (lastRun is null)
{
return Results.Ok(new RoutineRunSummaryDto(DateTime.MinValue, new List<RoutineRunEntryDto>()));
}
var summary = new RoutineRunSummaryDto(
lastRun.PerformedAt,
lastRun.Entries
.Select(e => new RoutineRunEntryDto(e.ExerciseId, e.Weight, e.Completed))
.ToList());
return Results.Ok(summary);
})
.WithSummary("Get last routine run")
.WithDescription("Returns the most recent run summary for a routine.");
group.MapPost("/users/{userId}/routines/{routineId:int}/runs", async (string userId, int routineId, RoutineRunRequest request, AppDbContext db) =>
{
var user = await UserProvisioning.EnsureUserAsync(db, userId);
var routine = await db.Routines.FirstOrDefaultAsync(r => r.Id == routineId && r.UserId == user.Id);
if (routine is null)
{
return Results.NotFound();
}
var run = new RoutineRun
{
RoutineId = routine.Id,
UserId = user.Id,
PerformedAt = DateTime.UtcNow,
Entries = request.Entries.Select(entry => new RoutineRunEntry
{
ExerciseId = entry.ExerciseId,
Weight = entry.Weight,
Completed = entry.Completed
}).ToList()
};
db.RoutineRuns.Add(run);
await db.SaveChangesAsync();
return Results.Ok(new { run.Id, run.PerformedAt });
})
.WithSummary("Create routine run")
.WithDescription("Records a routine run with exercise entries.");
return group;
}
}

View File

@@ -0,0 +1,40 @@
using ASTRAIN.Api.Data;
using ASTRAIN.Api.Services;
using ASTRAIN.Shared.Responses;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace ASTRAIN.Api.Endpoints;
/// <summary>
/// Provides user-related endpoints.
/// </summary>
internal static class UserEndpoints
{
/// <summary>
/// Registers user routes under the provided route group.
/// </summary>
/// <param name="group">The API route group.</param>
/// <returns>The same route group for chaining.</returns>
public static RouteGroupBuilder MapUserEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/users/ensure", async (string? userId, AppDbContext db) =>
{
var user = await UserProvisioning.EnsureUserAsync(db, userId);
return Results.Ok(new EnsureUserResponse(user.Id));
})
.WithSummary("Ensure user")
.WithDescription("Ensures a user exists and returns the user id.");
group.MapGet("/users/{userId}", async (string userId, AppDbContext db) =>
{
var user = await UserProvisioning.EnsureUserAsync(db, userId);
return Results.Ok(new EnsureUserResponse(user.Id));
})
.WithSummary("Get user")
.WithDescription("Returns the user id if it exists, or creates a new user.");
return group;
}
}

View File

@@ -1,10 +1,5 @@
using System.Text.RegularExpressions;
using ASTRAIN.Api.Data;
using ASTRAIN.Api.Services;
using ASTRAIN.Shared.Dtos;
using ASTRAIN.Shared.Models;
using ASTRAIN.Shared.Requests;
using ASTRAIN.Shared.Responses;
using ASTRAIN.Api.Endpoints;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
@@ -14,9 +9,13 @@ builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.WithOrigins("http://localhost:5014", "https://localhost:7252")
policy.AllowAnyOrigin()
.AllowAnyHeader()
.AllowAnyMethod();
// policy.WithOrigins("http://localhost:5016", "http://:5016", "https://localhost:7252")
// .AllowAnyHeader()
// .AllowAnyMethod();
});
});
builder.Services.AddDbContext<AppDbContext>(options =>
@@ -33,7 +32,7 @@ if (app.Environment.IsDevelopment())
app.MapOpenApi();
}
app.UseHttpsRedirection();
// app.UseHttpsRedirection();
app.UseCors();
app.UseDefaultFiles();
app.UseStaticFiles();
@@ -46,289 +45,12 @@ using (var scope = app.Services.CreateScope())
var api = app.MapGroup("/api");
api.MapGet("/health", () => Results.Ok(new { status = "ok" }));
api.MapGet("/users/ensure", async (string? userId, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
return Results.Ok(new EnsureUserResponse(user.Id));
});
api.MapGet("/users/{userId}", async (string userId, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
return Results.Ok(new EnsureUserResponse(user.Id));
});
api.MapGet("/users/{userId}/exercises", async (string userId, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
var items = await db.Exercises
.Where(e => e.UserId == user.Id)
.OrderBy(e => e.Name)
.Select(e => new ExerciseDto(e.Id, e.Name))
.ToListAsync();
return Results.Ok(items);
});
api.MapPost("/users/{userId}/exercises", async (string userId, ExerciseUpsertRequest request, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest("Name is required.");
}
var exercise = new Exercise
{
Name = request.Name.Trim(),
UserId = user.Id
};
db.Exercises.Add(exercise);
await db.SaveChangesAsync();
return Results.Ok(new ExerciseDto(exercise.Id, exercise.Name));
});
api.MapPut("/users/{userId}/exercises/{exerciseId:int}", async (string userId, int exerciseId, ExerciseUpsertRequest request, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
var exercise = await db.Exercises.FirstOrDefaultAsync(e => e.Id == exerciseId && e.UserId == user.Id);
if (exercise is null)
{
return Results.NotFound();
}
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest("Name is required.");
}
exercise.Name = request.Name.Trim();
await db.SaveChangesAsync();
return Results.Ok(new ExerciseDto(exercise.Id, exercise.Name));
});
api.MapGet("/users/{userId}/routines", async (string userId, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
var routines = await db.Routines
.Include(r => r.Exercises)
.ThenInclude(re => re.Exercise)
.Where(r => r.UserId == user.Id)
.OrderBy(r => r.Name)
.ToListAsync();
var payload = routines.Select(r => new RoutineDto(
r.Id,
r.Name,
r.Exercises
.OrderBy(re => re.Order)
.Select(re => new RoutineExerciseDto(re.ExerciseId, re.Exercise?.Name ?? string.Empty, re.Order))
.ToList()
));
return Results.Ok(payload);
});
api.MapGet("/users/{userId}/routines/{routineId:int}", async (string userId, int routineId, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
var routine = await db.Routines
.Include(r => r.Exercises)
.ThenInclude(re => re.Exercise)
.FirstOrDefaultAsync(r => r.Id == routineId && r.UserId == user.Id);
if (routine is null)
{
return Results.NotFound();
}
var payload = new RoutineDto(
routine.Id,
routine.Name,
routine.Exercises
.OrderBy(re => re.Order)
.Select(re => new RoutineExerciseDto(re.ExerciseId, re.Exercise?.Name ?? string.Empty, re.Order))
.ToList());
return Results.Ok(payload);
});
api.MapPost("/users/{userId}/routines", async (string userId, RoutineUpsertRequest request, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest("Name is required.");
}
var routine = new Routine
{
Name = request.Name.Trim(),
UserId = user.Id
};
var exercises = await db.Exercises
.Where(e => e.UserId == user.Id && request.ExerciseIds.Contains(e.Id))
.ToListAsync();
routine.Exercises = request.ExerciseIds
.Select((exerciseId, index) => new RoutineExercise
{
ExerciseId = exerciseId,
Order = index
})
.ToList();
db.Routines.Add(routine);
await db.SaveChangesAsync();
var dto = new RoutineDto(
routine.Id,
routine.Name,
routine.Exercises
.Select((re, index) => new RoutineExerciseDto(re.ExerciseId, exercises.FirstOrDefault(e => e.Id == re.ExerciseId)?.Name ?? string.Empty, index))
.ToList());
return Results.Ok(dto);
});
api.MapPut("/users/{userId}/routines/{routineId:int}", async (string userId, int routineId, RoutineUpsertRequest request, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
var routine = await db.Routines
.Include(r => r.Exercises)
.FirstOrDefaultAsync(r => r.Id == routineId && r.UserId == user.Id);
if (routine is null)
{
return Results.NotFound();
}
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest("Name is required.");
}
routine.Name = request.Name.Trim();
routine.Exercises.Clear();
foreach (var exerciseId in request.ExerciseIds)
{
routine.Exercises.Add(new RoutineExercise
{
ExerciseId = exerciseId,
Order = routine.Exercises.Count
});
}
await db.SaveChangesAsync();
var exercises = await db.Exercises
.Where(e => e.UserId == user.Id && request.ExerciseIds.Contains(e.Id))
.ToListAsync();
var dto = new RoutineDto(
routine.Id,
routine.Name,
routine.Exercises
.OrderBy(re => re.Order)
.Select(re => new RoutineExerciseDto(re.ExerciseId, exercises.FirstOrDefault(e => e.Id == re.ExerciseId)?.Name ?? string.Empty, re.Order))
.ToList());
return Results.Ok(dto);
});
api.MapGet("/users/{userId}/routines/{routineId:int}/last-run", async (string userId, int routineId, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
var lastRun = await db.RoutineRuns
.Include(rr => rr.Entries)
.Where(rr => rr.UserId == user.Id && rr.RoutineId == routineId)
.OrderByDescending(rr => rr.PerformedAt)
.FirstOrDefaultAsync();
if (lastRun is null)
{
return Results.Ok(new RoutineRunSummaryDto(DateTime.MinValue, new List<RoutineRunEntryDto>()));
}
var summary = new RoutineRunSummaryDto(
lastRun.PerformedAt,
lastRun.Entries
.Select(e => new RoutineRunEntryDto(e.ExerciseId, e.Weight, e.Completed))
.ToList());
return Results.Ok(summary);
});
api.MapPost("/users/{userId}/routines/{routineId:int}/runs", async (string userId, int routineId, RoutineRunRequest request, AppDbContext db) =>
{
var user = await EnsureUserAsync(db, userId);
var routine = await db.Routines.FirstOrDefaultAsync(r => r.Id == routineId && r.UserId == user.Id);
if (routine is null)
{
return Results.NotFound();
}
var run = new RoutineRun
{
RoutineId = routine.Id,
UserId = user.Id,
PerformedAt = DateTime.UtcNow,
Entries = request.Entries.Select(entry => new RoutineRunEntry
{
ExerciseId = entry.ExerciseId,
Weight = entry.Weight,
Completed = entry.Completed
}).ToList()
};
db.RoutineRuns.Add(run);
await db.SaveChangesAsync();
return Results.Ok(new { run.Id, run.PerformedAt });
});
api.MapHealthEndpoints();
api.MapUserEndpoints();
api.MapExerciseEndpoints();
api.MapRoutineEndpoints();
api.MapRoutineRunEndpoints();
app.MapFallbackToFile("index.html");
app.Run();
static async Task<User> EnsureUserAsync(AppDbContext db, string? userId)
{
if (!string.IsNullOrWhiteSpace(userId) && IsValidUserId(userId))
{
var existing = await db.Users.FirstOrDefaultAsync(u => u.Id == userId);
if (existing is not null)
{
return existing;
}
var created = new User { Id = userId };
db.Users.Add(created);
await db.SaveChangesAsync();
return created;
}
while (true)
{
var newId = UserKeyGenerator.Generate(8);
var exists = await db.Users.AnyAsync(u => u.Id == newId);
if (exists)
{
continue;
}
var user = new User { Id = newId };
db.Users.Add(user);
await db.SaveChangesAsync();
return user;
}
}
static bool IsValidUserId(string userId)
{
return Regex.IsMatch(userId, "^[A-Za-z0-9]{8}$");
}

View File

@@ -0,0 +1,60 @@
using System.Text.RegularExpressions;
using ASTRAIN.Api.Data;
using ASTRAIN.Shared.Models;
using Microsoft.EntityFrameworkCore;
namespace ASTRAIN.Api.Services;
/// <summary>
/// Provides helper methods for ensuring and validating application users.
/// </summary>
internal static class UserProvisioning
{
/// <summary>
/// Ensures a user exists in the database and returns the user record.
/// </summary>
/// <param name="db">The application database context.</param>
/// <param name="userId">Optional user id to validate or create.</param>
/// <returns>The existing or newly created user.</returns>
public static async Task<User> EnsureUserAsync(AppDbContext db, string? userId)
{
if (!string.IsNullOrWhiteSpace(userId) && IsValidUserId(userId))
{
var existing = await db.Users.FirstOrDefaultAsync(u => u.Id == userId);
if (existing is not null)
{
return existing;
}
var created = new User { Id = userId };
db.Users.Add(created);
await db.SaveChangesAsync();
return created;
}
while (true)
{
var newId = UserKeyGenerator.Generate(8);
var exists = await db.Users.AnyAsync(u => u.Id == newId);
if (exists)
{
continue;
}
var user = new User { Id = newId };
db.Users.Add(user);
await db.SaveChangesAsync();
return user;
}
}
/// <summary>
/// Determines whether a user id matches the expected format.
/// </summary>
/// <param name="userId">The user id to validate.</param>
/// <returns><c>true</c> if the id is valid; otherwise <c>false</c>.</returns>
public static bool IsValidUserId(string userId)
{
return Regex.IsMatch(userId, "^[A-Za-z0-9]{8}$");
}
}