From 092ef1743968398f279e75f66c28256a97639aff Mon Sep 17 00:00:00 2001 From: KS Jannette Date: Mon, 3 Aug 2026 04:23:32 -0400 Subject: [PATCH 1/2] fleshout backend --- AGENTS.md | 74 ++++++++++++++ README.md | 12 ++- backend/Controllers/InfoController.cs | 5 +- backend/Controllers/UserController.cs | 17 ---- backend/Controllers/UserRequests.cs | 13 +++ backend/Controllers/UsersController.cs | 96 +++++++++++++++++++ backend/Data/IUserRepository.cs | 17 ++++ .../Data/InMemory/InMemoryUserRepository.cs | 58 +++++++++++ backend/Data/PersistenceOptions.cs | 13 +++ .../PersistenceServiceCollectionExtensions.cs | 37 +++++++ backend/Data/User.cs | 15 +++ backend/Gateway/ApiRoutes.cs | 8 ++ .../GatewayApplicationBuilderExtensions.cs | 14 +++ backend/Gateway/GatewayOptions.cs | 19 ++++ .../GatewayServiceCollectionExtensions.cs | 64 +++++++++++++ backend/Program.cs | 29 +++--- backend/appsettings.json | 16 +++- backend/backend.csproj | 10 -- backend/backend.http | 35 +++++++ 19 files changed, 500 insertions(+), 52 deletions(-) create mode 100644 AGENTS.md delete mode 100644 backend/Controllers/UserController.cs create mode 100644 backend/Controllers/UserRequests.cs create mode 100644 backend/Controllers/UsersController.cs create mode 100644 backend/Data/InMemory/InMemoryUserRepository.cs create mode 100644 backend/Data/PersistenceOptions.cs create mode 100644 backend/Data/PersistenceServiceCollectionExtensions.cs create mode 100644 backend/Data/User.cs create mode 100644 backend/Gateway/ApiRoutes.cs create mode 100644 backend/Gateway/GatewayApplicationBuilderExtensions.cs create mode 100644 backend/Gateway/GatewayOptions.cs create mode 100644 backend/Gateway/GatewayServiceCollectionExtensions.cs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6c21ec0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,74 @@ +# AGENTS.md + +Skeleton monorepo for React + .NET projects. Clone it, then replace the placeholder +`User` domain with the real one. + +## Layout + +``` +backend/ ASP.NET Core Web API (net10.0) +frontend/ Vite + React + TypeScript SPA +``` + +## Running + +| Stack | Command | URL | +| --- | --- | --- | +| Backend | `cd backend && dotnet run` | `http://localhost:5231` | +| Frontend | `cd frontend && npm install && npm run dev` | `http://localhost:3000` | + +Start them in separate terminals. Allowed CORS origins live in `backend/appsettings.json` +under `Cors:AllowedOrigins`. + +## Verifying changes + +Run these before considering a change done: + +```bash +cd backend && dotnet build +cd frontend && npm run build && npm run lint +``` + +## Backend conventions + +- `ImplicitUsings` is enabled. Do not re-import `Microsoft.AspNetCore.*`, + `Microsoft.Extensions.DependencyInjection`, `System.Linq`, and friends. +- Use file-scoped namespaces (`namespace Backend.Data;`). +- Repository methods are async, suffixed `Async`, and take a `CancellationToken`. +- Read configuration through the options pattern (`IOptions` + `Configure`), + not `Configuration["Some:Key"]`. +- Controllers stay thin: validate, delegate to a repository or service, map the result + to an `IActionResult`. No data access in controllers. +- Route templates use `ApiRoutes.V1` rather than a hardcoded `"v1"` string. +- Cross-cutting pipeline concerns (CORS, rate limiting) belong in `backend/Gateway/` + and are wired through `AddApiGateway` / `UseApiGateway`, not inline in `Program.cs`. +- Persistence implementations register through `AddPersistence`, selected by the + `Persistence:Provider` setting. Swapping in EF Core or another store should not + require touching controllers. + +## Frontend conventions + +- All HTTP goes through `src/api/`. Components never call `fetch` directly. +- Configuration comes from `VITE_*` environment variables; `VITE_API_BASE_URL` points + at the backend. Add new variables to `.env.example` and to the `ImportMetaEnv` + interface in `src/vite-env.d.ts`. +- TypeScript is strict and unused locals are errors. The build runs `tsc -b` before Vite. + +## Endpoints + +| Method | Path | Purpose | +| --- | --- | --- | +| GET | `/health` | Liveness check, unversioned for infrastructure | +| GET | `/v1/info` | App name, version, environment, server timestamp | +| GET | `/v1/users` | List users | +| GET | `/v1/users/{id}` | Fetch one user | +| POST | `/v1/users` | Create a user | +| PUT | `/v1/users/{id}` | Replace a user | +| DELETE | `/v1/users/{id}` | Delete a user | + +Sample requests live in `backend/backend.http`. + +## Not included on purpose + +Authentication, a real database provider, tests, Docker, and structured logging are all +left out. Add them when the project has requirements that justify a specific choice. diff --git a/README.md b/README.md index fa4b69f..4b7a4fd 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,16 @@ The API runs at `http://localhost:5231`. Swagger UI is available in Development Skeleton endpoints: -- `GET /health` — liveness check +- `GET /health` — liveness check, exempt from rate limiting - `GET /v1/info` — app name, version, environment, and server timestamp +- `GET|POST /v1/users`, `GET|PUT|DELETE /v1/users/{id}` — placeholder CRUD over the + in-memory repository + +Requests are rate limited per client IP using the `RateLimiting` section of +`appsettings.json`, and allowed CORS origins come from the `Cors` section. Storage is +selected by `Persistence:Provider`, which currently supports `InMemory` only; data is +lost on restart until a real provider is registered in +`backend/Data/PersistenceServiceCollectionExtensions.cs`. ## Frontend @@ -46,7 +54,7 @@ VITE_API_BASE_URL=http://localhost:5231 ## Running both -Start the backend and frontend in separate terminals. The backend CORS policy allows requests from `http://localhost:3000`. +Start the backend and frontend in separate terminals. The backend CORS policy allows requests from `http://localhost:3000` by default; change `Cors:AllowedOrigins` in `backend/appsettings.json` to add more. ## Project structure diff --git a/backend/Controllers/InfoController.cs b/backend/Controllers/InfoController.cs index a980f82..0a766fa 100644 --- a/backend/Controllers/InfoController.cs +++ b/backend/Controllers/InfoController.cs @@ -1,10 +1,11 @@ +using Backend.Gateway; using Microsoft.AspNetCore.Mvc; namespace Backend.Controllers; -[Route("v1/[controller]")] [ApiController] -public class InfoController : ControllerBase +[Route(ApiRoutes.V1 + "/[controller]")] +public sealed class InfoController : ControllerBase { private readonly IWebHostEnvironment _environment; diff --git a/backend/Controllers/UserController.cs b/backend/Controllers/UserController.cs deleted file mode 100644 index 2f53567..0000000 --- a/backend/Controllers/UserController.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Backend.Data; - -namespace Backend.Controllers -{ - [Route("v1/[controller]")] - [ApiController] - public class UserController : ControllerBase - { - private readonly IUserRepository _userRepository; - - public UserController(IUserRepository userRepository) - { - _userRepository = userRepository; - } - } -} diff --git a/backend/Controllers/UserRequests.cs b/backend/Controllers/UserRequests.cs new file mode 100644 index 0000000..db16621 --- /dev/null +++ b/backend/Controllers/UserRequests.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace Backend.Controllers; + +// Validation attributes must target the primary constructor parameters, not the +// generated properties; MVC rejects record types with property-level metadata. +public sealed record CreateUserRequest( + [Required][EmailAddress] string Email, + [Required][StringLength(100, MinimumLength = 1)] string DisplayName); + +public sealed record UpdateUserRequest( + [Required][EmailAddress] string Email, + [Required][StringLength(100, MinimumLength = 1)] string DisplayName); diff --git a/backend/Controllers/UsersController.cs b/backend/Controllers/UsersController.cs new file mode 100644 index 0000000..18fb6ff --- /dev/null +++ b/backend/Controllers/UsersController.cs @@ -0,0 +1,96 @@ +using Backend.Data; +using Backend.Gateway; +using Microsoft.AspNetCore.Mvc; + +namespace Backend.Controllers; + +[ApiController] +[Route(ApiRoutes.V1 + "/[controller]")] +public sealed class UsersController : ControllerBase +{ + private readonly IUserRepository _userRepository; + + public UsersController(IUserRepository userRepository) + { + _userRepository = userRepository; + } + + [HttpGet] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task GetAll(CancellationToken cancellationToken) + => Ok(await _userRepository.GetAllAsync(cancellationToken)); + + [HttpGet("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetById(Guid id, CancellationToken cancellationToken) + { + var user = await _userRepository.GetByIdAsync(id, cancellationToken); + + return user is null ? NotFound() : Ok(user); + } + + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Create( + CreateUserRequest request, + CancellationToken cancellationToken) + { + if (await _userRepository.GetByEmailAsync(request.Email, cancellationToken) is not null) + { + return Conflict(new ProblemDetails + { + Title = "Email already in use", + Status = StatusCodes.Status409Conflict, + }); + } + + var user = await _userRepository.AddAsync( + new User { Email = request.Email, DisplayName = request.DisplayName }, + cancellationToken); + + return CreatedAtAction(nameof(GetById), new { id = user.Id }, user); + } + + [HttpPut("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Update( + Guid id, + UpdateUserRequest request, + CancellationToken cancellationToken) + { + var existing = await _userRepository.GetByIdAsync(id, cancellationToken); + + if (existing is null) + { + return NotFound(); + } + + var emailOwner = await _userRepository.GetByEmailAsync(request.Email, cancellationToken); + + if (emailOwner is not null && emailOwner.Id != id) + { + return Conflict(new ProblemDetails + { + Title = "Email already in use", + Status = StatusCodes.Status409Conflict, + }); + } + + existing.Email = request.Email; + existing.DisplayName = request.DisplayName; + + var updated = await _userRepository.UpdateAsync(existing, cancellationToken); + + return updated is null ? NotFound() : Ok(updated); + } + + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete(Guid id, CancellationToken cancellationToken) + => await _userRepository.DeleteAsync(id, cancellationToken) ? NoContent() : NotFound(); +} diff --git a/backend/Data/IUserRepository.cs b/backend/Data/IUserRepository.cs index 001e567..c1df8c4 100644 --- a/backend/Data/IUserRepository.cs +++ b/backend/Data/IUserRepository.cs @@ -1,5 +1,22 @@ namespace Backend.Data; +/// +/// Storage-agnostic access to . Implementations are selected by the +/// Persistence:Provider setting; see . +/// public interface IUserRepository { + Task> GetAllAsync(CancellationToken cancellationToken = default); + + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + Task GetByEmailAsync(string email, CancellationToken cancellationToken = default); + + Task AddAsync(User user, CancellationToken cancellationToken = default); + + /// The updated user, or null when no user has the given id. + Task UpdateAsync(User user, CancellationToken cancellationToken = default); + + /// true when a user was removed. + Task DeleteAsync(Guid id, CancellationToken cancellationToken = default); } diff --git a/backend/Data/InMemory/InMemoryUserRepository.cs b/backend/Data/InMemory/InMemoryUserRepository.cs new file mode 100644 index 0000000..bf1f5c9 --- /dev/null +++ b/backend/Data/InMemory/InMemoryUserRepository.cs @@ -0,0 +1,58 @@ +using System.Collections.Concurrent; + +namespace Backend.Data.InMemory; + +/// +/// Default implementation so the skeleton runs with no database. State is per-process +/// and lost on restart. +/// +public sealed class InMemoryUserRepository : IUserRepository +{ + private readonly ConcurrentDictionary _users = new(); + + public Task> GetAllAsync(CancellationToken cancellationToken = default) + { + IReadOnlyList users = _users.Values + .OrderBy(user => user.CreatedAt) + .ToList(); + + return Task.FromResult(users); + } + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + _users.TryGetValue(id, out var user); + + return Task.FromResult(user); + } + + public Task GetByEmailAsync(string email, CancellationToken cancellationToken = default) + { + var user = _users.Values + .FirstOrDefault(candidate => string.Equals(candidate.Email, email, StringComparison.OrdinalIgnoreCase)); + + return Task.FromResult(user); + } + + public Task AddAsync(User user, CancellationToken cancellationToken = default) + { + _users[user.Id] = user; + + return Task.FromResult(user); + } + + public Task UpdateAsync(User user, CancellationToken cancellationToken = default) + { + if (!_users.ContainsKey(user.Id)) + { + return Task.FromResult(null); + } + + _users[user.Id] = user; + + return Task.FromResult(user); + } + + public Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) + => Task.FromResult(_users.TryRemove(id, out _)); +} diff --git a/backend/Data/PersistenceOptions.cs b/backend/Data/PersistenceOptions.cs new file mode 100644 index 0000000..e37230d --- /dev/null +++ b/backend/Data/PersistenceOptions.cs @@ -0,0 +1,13 @@ +namespace Backend.Data; + +public sealed class PersistenceOptions +{ + public const string SectionName = "Persistence"; + + public string Provider { get; set; } = PersistenceProviders.InMemory; +} + +public static class PersistenceProviders +{ + public const string InMemory = "InMemory"; +} diff --git a/backend/Data/PersistenceServiceCollectionExtensions.cs b/backend/Data/PersistenceServiceCollectionExtensions.cs new file mode 100644 index 0000000..195cbe3 --- /dev/null +++ b/backend/Data/PersistenceServiceCollectionExtensions.cs @@ -0,0 +1,37 @@ +using Backend.Data.InMemory; + +namespace Backend.Data; + +/// +/// Single place where a storage backend is chosen. To add a real database, implement +/// and register it under a new provider name here; +/// controllers stay untouched. +/// +public static class PersistenceServiceCollectionExtensions +{ + public static IServiceCollection AddPersistence( + this IServiceCollection services, + IConfiguration configuration) + { + services.Configure(configuration.GetSection(PersistenceOptions.SectionName)); + + var provider = configuration.GetSection(PersistenceOptions.SectionName).Get()?.Provider + ?? PersistenceProviders.InMemory; + + switch (provider) + { + case PersistenceProviders.InMemory: + // Singleton because the store itself holds the data. + services.AddSingleton(); + break; + + default: + throw new InvalidOperationException( + $"Unknown persistence provider '{provider}'. Supported providers: " + + $"'{PersistenceProviders.InMemory}'. Register additional providers in " + + $"{nameof(PersistenceServiceCollectionExtensions)}."); + } + + return services; + } +} diff --git a/backend/Data/User.cs b/backend/Data/User.cs new file mode 100644 index 0000000..499f4f3 --- /dev/null +++ b/backend/Data/User.cs @@ -0,0 +1,15 @@ +namespace Backend.Data; + +/// +/// Placeholder domain entity. Replace with the real model once the project has one. +/// +public sealed class User +{ + public Guid Id { get; init; } = Guid.NewGuid(); + + public required string Email { get; set; } + + public required string DisplayName { get; set; } + + public DateTime CreatedAt { get; init; } = DateTime.UtcNow; +} diff --git a/backend/Gateway/ApiRoutes.cs b/backend/Gateway/ApiRoutes.cs new file mode 100644 index 0000000..82b09ed --- /dev/null +++ b/backend/Gateway/ApiRoutes.cs @@ -0,0 +1,8 @@ +namespace Backend.Gateway; + +public static class ApiRoutes +{ + public const string V1 = "v1"; + + public const string Health = "/health"; +} diff --git a/backend/Gateway/GatewayApplicationBuilderExtensions.cs b/backend/Gateway/GatewayApplicationBuilderExtensions.cs new file mode 100644 index 0000000..7231160 --- /dev/null +++ b/backend/Gateway/GatewayApplicationBuilderExtensions.cs @@ -0,0 +1,14 @@ +namespace Backend.Gateway; + +public static class GatewayApplicationBuilderExtensions +{ + // CORS must run before the rate limiter so that rejected requests still carry + // the headers the browser needs to surface a 429 to the client. + public static IApplicationBuilder UseApiGateway(this IApplicationBuilder app) + { + app.UseCors(GatewayServiceCollectionExtensions.CorsPolicyName); + app.UseRateLimiter(); + + return app; + } +} diff --git a/backend/Gateway/GatewayOptions.cs b/backend/Gateway/GatewayOptions.cs new file mode 100644 index 0000000..7a872ac --- /dev/null +++ b/backend/Gateway/GatewayOptions.cs @@ -0,0 +1,19 @@ +namespace Backend.Gateway; + +public sealed class CorsOptions +{ + public const string SectionName = "Cors"; + + public string[] AllowedOrigins { get; set; } = []; +} + +public sealed class RateLimitOptions +{ + public const string SectionName = "RateLimiting"; + + public int PermitLimit { get; set; } = 100; + + public int WindowSeconds { get; set; } = 60; + + public int QueueLimit { get; set; } +} diff --git a/backend/Gateway/GatewayServiceCollectionExtensions.cs b/backend/Gateway/GatewayServiceCollectionExtensions.cs new file mode 100644 index 0000000..51d6204 --- /dev/null +++ b/backend/Gateway/GatewayServiceCollectionExtensions.cs @@ -0,0 +1,64 @@ +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.RateLimiting; + +namespace Backend.Gateway; + +public static class GatewayServiceCollectionExtensions +{ + public const string CorsPolicyName = "DefaultCorsPolicy"; + + public static IServiceCollection AddApiGateway( + this IServiceCollection services, + IConfiguration configuration) + { + services.Configure(configuration.GetSection(CorsOptions.SectionName)); + services.Configure(configuration.GetSection(RateLimitOptions.SectionName)); + + var corsSettings = configuration.GetSection(CorsOptions.SectionName).Get() + ?? new CorsOptions(); + var rateLimitSettings = configuration.GetSection(RateLimitOptions.SectionName).Get() + ?? new RateLimitOptions(); + + services.AddCors(options => options.AddPolicy(CorsPolicyName, policy => + { + if (corsSettings.AllowedOrigins.Length == 0) + { + return; + } + + policy.WithOrigins(corsSettings.AllowedOrigins) + .AllowAnyHeader() + .AllowAnyMethod(); + })); + + services.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.GlobalLimiter = CreateGlobalLimiter(rateLimitSettings); + }); + + services.Configure(options => options.LowercaseUrls = true); + + return services; + } + + // Health checks are exempt so that infrastructure probes are never throttled. + private static PartitionedRateLimiter CreateGlobalLimiter(RateLimitOptions settings) => + PartitionedRateLimiter.Create(context => + { + if (context.Request.Path.StartsWithSegments(ApiRoutes.Health)) + { + return RateLimitPartition.GetNoLimiter("health"); + } + + var partitionKey = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + + return RateLimitPartition.GetFixedWindowLimiter(partitionKey, _ => new FixedWindowRateLimiterOptions + { + PermitLimit = settings.PermitLimit, + Window = TimeSpan.FromSeconds(settings.WindowSeconds), + QueueLimit = settings.QueueLimit, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + }); + }); +} diff --git a/backend/Program.cs b/backend/Program.cs index be8d609..869a1cd 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -1,42 +1,35 @@ using System.Text.Json; +using Backend.Data; +using Backend.Gateway; using Microsoft.AspNetCore.Diagnostics.HealthChecks; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; -// using Backend.Data + var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddHealthChecks(); +builder.Services.AddProblemDetails(); -builder.Services.AddCors(); - -//builder.Services.AddDbContext(options => -// options.UseSqlite(builder.Configuration["ConnectionStrings:UserConnection"]) -//); - -//builder.Services.AddScoped(); +builder.Services.AddApiGateway(builder.Configuration); +builder.Services.AddPersistence(builder.Configuration); var app = builder.Build(); -// Configure the HTTP request pipeline. +app.UseExceptionHandler(); + if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } -app.UseCors(p => p.WithOrigins("http://localhost:3000")); - app.UseHttpsRedirection(); -app.UseAuthorization(); +app.UseApiGateway(); -app.MapHealthChecks("/health", new HealthCheckOptions +app.MapHealthChecks(ApiRoutes.Health, new HealthCheckOptions { ResponseWriter = async (context, report) => { @@ -51,4 +44,4 @@ app.MapHealthChecks("/health", new HealthCheckOptions app.MapControllers(); -app.Run(); \ No newline at end of file +app.Run(); diff --git a/backend/appsettings.json b/backend/appsettings.json index 7380ed6..a9f13f1 100644 --- a/backend/appsettings.json +++ b/backend/appsettings.json @@ -6,7 +6,17 @@ } }, "AllowedHosts": "*", - "ConnectionStrings": { - "BowlingLeagueConnection": "Data Source=BowlingLeague.sqlite" + "Cors": { + "AllowedOrigins": [ + "http://localhost:3000" + ] + }, + "RateLimiting": { + "PermitLimit": 100, + "WindowSeconds": 60, + "QueueLimit": 0 + }, + "Persistence": { + "Provider": "InMemory" } -} \ No newline at end of file +} diff --git a/backend/backend.csproj b/backend/backend.csproj index 41b8026..33fd0b3 100644 --- a/backend/backend.csproj +++ b/backend/backend.csproj @@ -7,16 +7,6 @@ - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - diff --git a/backend/backend.http b/backend/backend.http index f8b88c7..209a5c1 100644 --- a/backend/backend.http +++ b/backend/backend.http @@ -1,4 +1,5 @@ @Backend_HostAddress = http://localhost:5231 +@UserId = 00000000-0000-0000-0000-000000000000 GET {{Backend_HostAddress}}/health Accept: application/json @@ -9,3 +10,37 @@ GET {{Backend_HostAddress}}/v1/info Accept: application/json ### + +GET {{Backend_HostAddress}}/v1/users +Accept: application/json + +### + +POST {{Backend_HostAddress}}/v1/users +Content-Type: application/json + +{ + "email": "ada@example.com", + "displayName": "Ada Lovelace" +} + +### + +GET {{Backend_HostAddress}}/v1/users/{{UserId}} +Accept: application/json + +### + +PUT {{Backend_HostAddress}}/v1/users/{{UserId}} +Content-Type: application/json + +{ + "email": "ada@example.com", + "displayName": "Ada King" +} + +### + +DELETE {{Backend_HostAddress}}/v1/users/{{UserId}} + +### From ae774207532086cb673c4679e58b7e027e6a05b0 Mon Sep 17 00:00:00 2001 From: KS Jannette Date: Mon, 3 Aug 2026 04:24:03 -0400 Subject: [PATCH 2/2] Add to readme --- README.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4b7a4fd..4fd4054 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,20 @@ -# react-dotnet +# React-Dotnet Frewaork Sekelon -### A minimal monorepo skeleton - full-stask project jumpstarter with: +Once upon a time, people maintinaed "skeleton" repos they could clone to quickly jumpstart new projects. Why? It was quick, free and it worked. -### NET backend +### A. Always. B. Be. C. Cognitivaly TokenWoxxxening + +### This is a full-stask project jumpstarter with: + +### .NET backend ### React/Vite frontend +# .Net + Five CRUD endpoints return correct status codes: + + 201 with a proper Location header + 400 on invalid email, 409 on duplicate, 204 then 404 on delete), 150 requests to /v1/info yielded exactly 100 successes and the rest 429, and 40 requests to /health all returned 200 while that window was exhausted. + ## Prerequisites - [.NET SDK](https://dotnet.microsoft.com/download) (project targets `net10.0`)