Watch
1
0
Fork
You've already forked test-web
0

Initial commit
All checks were successful
Build / build (push) Successful in 43s

This commit is contained in:
Chris 2026-06-06 10:39:37 +01:00
commit 1199bef0c8
45 changed files with 1200 additions and 0 deletions

View file

@ -0,0 +1,40 @@
namespace TestWeb.Core.Jobs;
using Infra;
public static class CreateJobEndpoint
{
public record Request(
string ClientName,
string Pickup,
string Dropoff
);
public record Response(int Id);
public class RequestValidator : AbstractValidator<Request>
{
public RequestValidator()
{
RuleFor(x => x.ClientName).NotEmpty().MaximumLength(250);
RuleFor(x => x.Pickup).NotEmpty().MaximumLength(120);
RuleFor(x => x.Dropoff).NotEmpty().MaximumLength(120);
}
}
public static async Task<Ok<Response>> Handle(Request request, AppDbContext db, CancellationToken ct)
{
var row = new Job
{
ClientName = request.ClientName,
Pickup = request.Pickup,
Dropoff = request.Dropoff,
};
await db.Jobs.AddAsync(row, ct);
await db.SaveChangesAsync(ct);
var response = new Response(row.Id);
return TypedResults.Ok(response);
}
}

View file

@ -0,0 +1,20 @@
namespace TestWeb.Core.Jobs;
using Infra;
public static class DeleteJobEndpoint
{
public static async Task<Results<Ok, NotFound>> Handle(
int id,
AppDbContext db,
CancellationToken ct)
{
var rowsDeleted = await db.Jobs
.Where(x => x.Id == id)
.ExecuteDeleteAsync(ct);
return rowsDeleted == 1
? TypedResults.Ok()
: TypedResults.NotFound();
}
}

View file

@ -0,0 +1,20 @@
namespace TestWeb.Core.Jobs;
using Infra;
public static class GetJobEndpoint
{
public static async Task<Results<Ok<Job>, NotFound>> Handle(
int id,
AppDbContext db,
CancellationToken ct)
{
var result = await db.Jobs
.Where(x => x.Id == id)
.SingleOrDefaultAsync(ct);
return result is null
? TypedResults.NotFound()
: TypedResults.Ok(result);
}
}

View file

@ -0,0 +1,12 @@
namespace TestWeb.Core.Jobs;
using Infra;
public static class GetJobsEndpoint
{
public static async Task<Ok<Job[]>> Handle(AppDbContext db, CancellationToken ct)
{
var results = await db.Jobs.ToArrayAsync(ct);
return TypedResults.Ok(results);
}
}

26
src/Core/Jobs/JobsModule.cs Executable file
View file

@ -0,0 +1,26 @@
namespace TestWeb.Core.Jobs;
using Utils;
public static class JobsModule
{
public const string Prefix = "/api/jobs";
public static void MapEndpoints(IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup(Prefix)
.WithTags("Jobs");
group.MapPost("/", CreateJobEndpoint.Handle)
.WithSummary("Create a job")
.WithRequestValidation<CreateJobEndpoint.Request>();
group.MapDelete("/{id:int}", DeleteJobEndpoint.Handle)
.WithSummary("Delete job by id");
group.MapGet("/{id:int}", GetJobEndpoint.Handle)
.WithSummary("Get job by id");
group.MapGet("/", GetJobsEndpoint.Handle)
.WithSummary("Get job list");
group.MapPut("/{id:int}", UpdateJobEndpoint.Handle)
.WithSummary("Update job by id")
.WithRequestValidation<UpdateJobEndpoint.Request>();
}
}

View file

@ -0,0 +1,47 @@
namespace TestWeb.Core.Jobs;
using Infra;
public static class UpdateJobEndpoint
{
public record Request(
string ClientName,
string Pickup,
string Dropoff
);
public class RequestValidator : AbstractValidator<Request>
{
public RequestValidator()
{
RuleFor(x => x.ClientName).NotEmpty().MaximumLength(250);
RuleFor(x => x.Pickup).NotEmpty().MaximumLength(120);
RuleFor(x => x.Dropoff).NotEmpty().MaximumLength(120);
}
}
public static async Task<Results<Ok, NotFound>> Handle(
int id,
Request request,
AppDbContext db,
CancellationToken ct)
{
var result = await db.Jobs
.Where(x => x.Id == id)
.SingleOrDefaultAsync(ct);
if (result == null)
{
return TypedResults.NotFound();
}
result.ClientName = request.ClientName;
result.Pickup = request.Pickup;
result.Dropoff = request.Dropoff;
result.LastUpdated = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return TypedResults.Ok();
}
}

View file

@ -0,0 +1,34 @@
namespace TestWeb.Core.Users;
using Infra;
public static class CreateUserEndpoint
{
public record Request(string UserName);
public class RequestValidator : AbstractValidator<Request>
{
public RequestValidator()
{
RuleFor(x => x.UserName).NotEmpty().MaximumLength(250);
}
}
public record Response(int Id);
public static async Task<Ok<Response>> Handle(Request request, AppDbContext db, CancellationToken ct)
{
var row = new User
{
Username = request.UserName,
DisplayName = request.UserName,
Password = string.Empty
};
await db.Users.AddAsync(row, ct);
await db.SaveChangesAsync(ct);
var response = new Response(row.Id);
return TypedResults.Ok(response);
}
}

View file

@ -0,0 +1,21 @@
using TestWeb.Infra;
using TestWeb.Utils;
namespace TestWeb.Core.Users;
public static class DeleteUserEndpoint
{
public static async Task<Results<Ok, NotFound>> Handle(
int id,
AppDbContext db,
CancellationToken ct)
{
var rowsDeleted = await db.Users
.Where(x => x.Id == id)
.ExecuteDeleteAsync(ct);
return rowsDeleted == 1
? TypedResults.Ok()
: TypedResults.NotFound();
}
}

View file

@ -0,0 +1,20 @@
namespace TestWeb.Core.Users;
using Infra;
public static class GetUserEndpoint
{
public static async Task<Results<Ok<User>, NotFound>> Handle(
int id,
AppDbContext db,
CancellationToken ct)
{
var result = await db.Users
.Where(x => x.Id == id)
.SingleOrDefaultAsync(ct);
return result is null
? TypedResults.NotFound()
: TypedResults.Ok(result);
}
}

View file

@ -0,0 +1,12 @@
namespace TestWeb.Core.Users;
using Infra;
public static class GetUsersEndpoint
{
public static async Task<Ok<User[]>> Handle(AppDbContext db, CancellationToken ct)
{
var results = await db.Users.ToArrayAsync(ct);
return TypedResults.Ok(results);
}
}

View file

@ -0,0 +1,39 @@
namespace TestWeb.Core.Users;
using Infra;
public static class UpdateUserEndpoint
{
public record Request(string DisplayName);
public class RequestValidator : AbstractValidator<Request>
{
public RequestValidator()
{
RuleFor(x => x.DisplayName).NotEmpty().MaximumLength(250);
}
}
public static async Task<Results<Ok, NotFound>> Handle(
int id,
Request request,
AppDbContext db,
CancellationToken ct)
{
var result = await db.Users
.Where(x => x.Id == id)
.SingleOrDefaultAsync(ct);
if (result == null)
{
return TypedResults.NotFound();
}
result.DisplayName = request.DisplayName;
result.LastUpdated = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return TypedResults.Ok();
}
}

26
src/Core/Users/UsersModule.cs Executable file
View file

@ -0,0 +1,26 @@
namespace TestWeb.Core.Users;
using Utils;
public static class UsersModule
{
public const string Prefix = "/api/users";
public static void MapEndpoints(IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup(Prefix)
.WithTags("Users");
group.MapPost("/", CreateUserEndpoint.Handle)
.WithSummary("Create a user")
.WithRequestValidation<CreateUserEndpoint.Request>();
group.MapDelete("/{id}", DeleteUserEndpoint.Handle)
.WithSummary("Delete user by id");
group.MapGet("/{id}", GetUserEndpoint.Handle)
.WithSummary("Get user by id");
group.MapGet("/", GetUsersEndpoint.Handle)
.WithSummary("Get user list");
group.MapPut("/{id}", UpdateUserEndpoint.Handle)
.WithSummary("Update user by id")
.WithRequestValidation<UpdateUserEndpoint.Request>();
}
}

View file

@ -0,0 +1,35 @@
namespace TestWeb.Core.Vehicles;
using Infra;
public static class CreateVehicleEndpoint
{
public record Request(string Make, string Model);
public record Response(int Id);
public class RequestValidator : AbstractValidator<Request>
{
public RequestValidator()
{
RuleFor(x => x.Make).NotEmpty().MaximumLength(250);
RuleFor(x => x.Model).NotEmpty().MaximumLength(250);
}
}
public static async Task<Ok<Response>> Handle(Request request, AppDbContext db, CancellationToken ct)
{
var row = new Vehicle
{
Make = request.Make,
Model = request.Model,
Year = DateTime.Now.Year
};
await db.Vehicles.AddAsync(row, ct);
await db.SaveChangesAsync(ct);
var response = new Response(row.Id);
return TypedResults.Ok(response);
}
}

View file

@ -0,0 +1,20 @@
namespace TestWeb.Core.Vehicles;
using Infra;
public static class DeleteVehicleEndpoint
{
public static async Task<Results<Ok, NotFound>> Handle(
int id,
AppDbContext db,
CancellationToken ct)
{
var rowsDeleted = await db.Vehicles
.Where(x => x.Id == id)
.ExecuteDeleteAsync(ct);
return rowsDeleted == 1
? TypedResults.Ok()
: TypedResults.NotFound();
}
}

View file

@ -0,0 +1,20 @@
namespace TestWeb.Core.Vehicles;
using Infra;
public static class GetVehicleEndpoint
{
public static async Task<Results<Ok<Vehicle>, NotFound>> Handle(
int id,
AppDbContext db,
CancellationToken ct)
{
var result = await db.Vehicles
.Where(x => x.Id == id)
.SingleOrDefaultAsync(ct);
return result is null
? TypedResults.NotFound()
: TypedResults.Ok(result);
}
}

View file

@ -0,0 +1,12 @@
namespace TestWeb.Core.Vehicles;
using Infra;
public static class GetVehiclesEndpoint
{
public static async Task<Ok<Vehicle[]>> Handle(AppDbContext db, CancellationToken ct)
{
var results = await db.Vehicles.ToArrayAsync(ct);
return TypedResults.Ok(results);
}
}

View file

@ -0,0 +1,41 @@
namespace TestWeb.Core.Vehicles;
using Infra;
public static class UpdateVehicleEndpoint
{
public record Request(string Make, string Model);
public class RequestValidator : AbstractValidator<Request>
{
public RequestValidator()
{
RuleFor(x => x.Make).NotEmpty().MaximumLength(250);
RuleFor(x => x.Model).NotEmpty().MaximumLength(250);
}
}
public static async Task<Results<Ok, NotFound>> Handle(
int id,
Request request,
AppDbContext db,
CancellationToken ct)
{
var result = await db.Vehicles
.Where(x => x.Id == id)
.SingleOrDefaultAsync(ct);
if (result == null)
{
return TypedResults.NotFound();
}
result.Make = request.Make;
result.Model = request.Model;
result.LastUpdated = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return TypedResults.Ok();
}
}

View file

@ -0,0 +1,26 @@
namespace TestWeb.Core.Vehicles;
using Utils;
public static class VehiclesModule
{
public const string Prefix = "/api/vehicles";
public static void MapEndpoints(IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup(Prefix)
.WithTags("Vehicles");
group.MapPost("/", CreateVehicleEndpoint.Handle)
.WithSummary("Create a vehicle")
.WithRequestValidation<CreateVehicleEndpoint.Request>();
group.MapDelete("/{id}", DeleteVehicleEndpoint.Handle)
.WithSummary("Delete vehicle by id");
group.MapGet("/{id}", GetVehicleEndpoint.Handle)
.WithSummary("Get vehicle by id");
group.MapGet("/", GetVehiclesEndpoint.Handle)
.WithSummary("Get vehicle list");
group.MapPut("/{id}", UpdateVehicleEndpoint.Handle)
.WithSummary("Update vehicle by id")
.WithRequestValidation<UpdateVehicleEndpoint.Request>();
}
}

View file

@ -0,0 +1,25 @@
namespace TestWeb.Core.WeatherForecasts;
public record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
public static class GetWeatherForecastsEndpoint
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
public static WeatherForecast[] Handle()
{
var random = new Random();
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
(
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
random.Next(-20, 55),
Summaries[random.Next(Summaries.Length)]
)).ToArray();
}
}

View file

@ -0,0 +1,15 @@
namespace TestWeb.Core.WeatherForecasts;
public static class WeatherForecastsModule
{
public const string Prefix = "/api/weatherforecasts";
public static void MapEndpoints(IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/weatherforecasts")
.WithTags("WeatherForecasts")
.AllowAnonymous();
group.MapGet("/", GetWeatherForecastsEndpoint.Handle)
.WithSummary("Get weather forecast list");
}
}

4
src/Global.Usings.cs Normal file
View file

@ -0,0 +1,4 @@
global using FluentValidation;
global using Microsoft.AspNetCore.Authentication.JwtBearer;
global using Microsoft.AspNetCore.Http.HttpResults;
global using Microsoft.EntityFrameworkCore;

17
src/Infra/AppDbContext.cs Executable file
View file

@ -0,0 +1,17 @@
namespace TestWeb.Infra;
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<Job> Jobs
{
get; set;
}
public DbSet<User> Users
{
get; set;
}
public DbSet<Vehicle> Vehicles
{
get; set;
}
}

33
src/Infra/Job.cs Executable file
View file

@ -0,0 +1,33 @@
using System.ComponentModel.DataAnnotations;
namespace TestWeb.Infra;
public class Job
{
[Key]
public int Id
{
get; init;
}
public required string ClientName
{
get; set;
}
public required string Pickup
{
get; set;
}
public string? Dropoff
{
get; set;
}
public int VehicleId
{
get; set;
}
public DateTime DateCreated { get; private init; } = DateTime.UtcNow;
public DateTime? LastUpdated
{
get; set;
}
}

29
src/Infra/User.cs Executable file
View file

@ -0,0 +1,29 @@
using System.ComponentModel.DataAnnotations;
namespace TestWeb.Infra;
public class User
{
[Key]
public int Id
{
get; init;
}
public required string Username
{
get; set;
}
public required string Password
{
get; set;
}
public required string DisplayName
{
get; set;
}
public DateTime DateCreated { get; private init; } = DateTime.UtcNow;
public DateTime? LastUpdated
{
get; set;
}
}

29
src/Infra/Vehicle.cs Executable file
View file

@ -0,0 +1,29 @@
using System.ComponentModel.DataAnnotations;
namespace TestWeb.Infra;
public class Vehicle
{
[Key]
public int Id
{
get; init;
}
public required string Make
{
get; set;
}
public required string Model
{
get; set;
}
public required int Year
{
get; set;
}
public DateTime DateCreated { get; private init; } = DateTime.UtcNow;
public DateTime? LastUpdated
{
get; set;
}
}

86
src/Program.cs Executable file
View file

@ -0,0 +1,86 @@
using Microsoft.OpenApi.Models;
using TestWeb.Core.Jobs;
using TestWeb.Core.Users;
using TestWeb.Core.Vehicles;
using TestWeb.Core.WeatherForecasts;
using TestWeb.Infra;
using TestWeb.Utils;
namespace TestWeb;
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
AddServices(builder);
var app = builder.Build();
Configure(app);
app.Run();
}
public static void AddServices(WebApplicationBuilder builder)
{
builder.Services.AddDbContext<AppDbContext>(
options => options.UseSqlite("DataSource=app.db"));
builder.Services.AddOpenApi();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddCors(options =>
{
options.AddPolicy(
"CorsPolicy",
builder => builder
.AllowAnyOrigin()
.AllowAnyHeader()
.AllowAnyMethod()
);
});
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);
}
public static void Configure(WebApplication app)
{
app.MapOpenApi();
app.UseSwagger();
app.UseSwaggerUI();
app.UseDeveloperExceptionPage();
app.UseCors("CorsPolicy");
app.UseHttpsRedirection();
app.UseRouting();
var endpoints = app.MapGroup("")
.AddEndpointFilter<RequestLoggingFilter>();
var securityScheme = new OpenApiSecurityScheme()
{
Type = SecuritySchemeType.Http,
Name = JwtBearerDefaults.AuthenticationScheme,
Scheme = JwtBearerDefaults.AuthenticationScheme,
Reference = new()
{
Type = ReferenceType.SecurityScheme,
Id = JwtBearerDefaults.AuthenticationScheme
}
};
endpoints.WithOpenApi(x => new()
{
Security = [new() { [securityScheme] = [] }]
});
JobsModule.MapEndpoints(endpoints);
UsersModule.MapEndpoints(endpoints);
VehiclesModule.MapEndpoints(endpoints);
WeatherForecastsModule.MapEndpoints(endpoints);
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.EnsureCreated();
}
}

16
src/TestWeb.csproj Executable file
View file

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.6" />
<PackageReference Include="FluentValidation" Version="12.0.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.6" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.6" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,15 @@
namespace TestWeb.Utils;
public class RequestLoggingFilter(ILogger<RequestLoggingFilter> logger) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
logger.LogInformation(
"HTTP {Method} {Path} recieved",
context.HttpContext.Request.Method,
context.HttpContext.Request.Path);
return await next(context);
}
}

View file

@ -0,0 +1,34 @@
namespace TestWeb.Utils;
public class RequestValidationFilter<TRequest>(
ILogger<RequestValidationFilter<TRequest>> logger,
IValidator<TRequest>? validator = null) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var requestName = typeof(TRequest).FullName;
if (validator is null)
{
logger.LogInformation("{Request}: No validator configured.", requestName);
return await next(context);
}
logger.LogInformation("{Request}: Validating...", requestName);
var request = context.Arguments.OfType<TRequest>().First();
var validationResult = await validator.ValidateAsync(
request,
context.HttpContext.RequestAborted
);
if (!validationResult.IsValid)
{
logger.LogWarning("{Request}: Validation failed.", requestName);
return TypedResults.ValidationProblem(validationResult.ToDictionary());
}
logger.LogInformation("{Request}: Validation succeeded.", requestName);
return await next(context);
}
}

View file

@ -0,0 +1,12 @@
namespace TestWeb.Utils;
public static class ValidationExtensions
{
public static RouteHandlerBuilder WithRequestValidation<TRequest>(
this RouteHandlerBuilder builder)
{
return builder
.AddEndpointFilter<RequestValidationFilter<TRequest>>()
.ProducesValidationProblem();
}
}

19
src/appsettings.json Executable file
View file

@ -0,0 +1,19 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"System": "Information",
"Microsoft": "Information"
},
"Console": {
"IncludeScopes": true
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"Default": "Server=.;Database=TestWeb;Trusted_Connection=True;Encrypt=False;"
},
"Jwt": {
"Key": "superdupersecretkeythatsreallyreallylong"
}
}