Skip to content
Merged
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
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,12 @@ It aims to help development teams inspect API requests, application errors, audi
- Sample ASP.NET Core application
- Foundation for an installable ASP.NET Core SDK
- GitHub Actions continuous integration
- Project management REST API

## Planned Features

- Project, environment, and API key persistence with EF Core and PostgreSQL
- Project and environment management API
- Environment management API
- API key creation and revocation endpoints
- Request, exception, audit, and email event ingestion
- Sensitive-data redaction
Expand Down Expand Up @@ -120,6 +121,29 @@ Apply database migrations manually before starting the API:
dotnet ef database update --project src/FCode.TraceHub.Infrastructure --startup-project src/FCode.TraceHub.Api
```

## Project API

Projects can be created, listed, retrieved, updated, and archived through
`/api/projects`. Slugs are normalized to lowercase, and archived projects are
retained with `isActive` set to `false`.

```bash
curl -X POST http://localhost:5177/api/projects \
-H "Content-Type: application/json" \
-d '{"name":"TraceHub API","slug":"tracehub-api","description":"Main API"}'

curl http://localhost:5177/api/projects
curl http://localhost:5177/api/projects/{id}

curl -X PUT http://localhost:5177/api/projects/{id} \
-H "Content-Type: application/json" \
-d '{"name":"TraceHub API","slug":"tracehub-api","description":"Updated API"}'

curl -X DELETE http://localhost:5177/api/projects/{id}
```

Validation, missing-resource, and duplicate-slug responses use ProblemDetails.

Check dependencies for known vulnerabilities:

```bash
Expand Down
83 changes: 83 additions & 0 deletions src/FCode.TraceHub.Api/Controllers/ProjectsController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using FCode.TraceHub.Application.Projects;
using Microsoft.AspNetCore.Mvc;

namespace FCode.TraceHub.Api.Controllers;

[ApiController]
[Route("api/projects")]
[Produces("application/json")]
[Tags("Projects")]
public sealed class ProjectsController(IProjectService projectService) :
ControllerBase
{
[HttpPost(Name = "CreateProject")]
[ProducesResponseType<ProjectResponse>(StatusCodes.Status201Created)]
[ProducesResponseType<ValidationProblemDetails>(
StatusCodes.Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ProjectResponse>> Create(
CreateProjectRequest request,
CancellationToken cancellationToken)
{
var project = await projectService.CreateAsync(
request,
cancellationToken);

return CreatedAtAction(
nameof(GetById),
new { id = project.Id },
project);
}

[HttpGet(Name = "ListProjects")]
[ProducesResponseType<IReadOnlyList<ProjectResponse>>(
StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<ProjectResponse>>> List(
CancellationToken cancellationToken)
{
var projects = await projectService.ListAsync(cancellationToken);
return Ok(projects);
}

[HttpGet("{id:guid}", Name = "GetProjectById")]
[ProducesResponseType<ProjectResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ProjectResponse>> GetById(
Guid id,
CancellationToken cancellationToken)
{
var project = await projectService.GetByIdAsync(
id,
cancellationToken);
return Ok(project);
}

[HttpPut("{id:guid}", Name = "UpdateProject")]
[ProducesResponseType<ProjectResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ValidationProblemDetails>(
StatusCodes.Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ProjectResponse>> Update(
Guid id,
UpdateProjectRequest request,
CancellationToken cancellationToken)
{
var project = await projectService.UpdateAsync(
id,
request,
cancellationToken);
return Ok(project);
}

[HttpDelete("{id:guid}", Name = "ArchiveProject")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Archive(
Guid id,
CancellationToken cancellationToken)
{
await projectService.ArchiveAsync(id, cancellationToken);
return NoContent();
}
}
41 changes: 41 additions & 0 deletions src/FCode.TraceHub.Api/ErrorHandling/ApiExceptionHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using FCode.TraceHub.Application.Projects;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;

namespace FCode.TraceHub.Api.ErrorHandling;

public sealed class ApiExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
IResult? result = exception switch
{
ProjectValidationException validationException =>
Results.ValidationProblem(
validationException.Errors,
statusCode: StatusCodes.Status400BadRequest,
title: "Project validation failed."),
ProjectNotFoundException notFoundException => Results.Problem(
statusCode: StatusCodes.Status404NotFound,
title: "Project not found.",
detail: notFoundException.Message),
DuplicateProjectSlugException conflictException =>
Results.Problem(
statusCode: StatusCodes.Status409Conflict,
title: "Project slug conflict.",
detail: conflictException.Message),
_ => null
};

if (result is null)
{
return false;
}

await result.ExecuteAsync(httpContext);
return true;
}
}
31 changes: 30 additions & 1 deletion src/FCode.TraceHub.Api/FCode.TraceHub.Api.http
Original file line number Diff line number Diff line change
@@ -1,6 +1,35 @@
@FCode.TraceHub.Api_HostAddress = http://localhost:5177

GET {{FCode.TraceHub.Api_HostAddress}}/weatherforecast/
POST {{FCode.TraceHub.Api_HostAddress}}/api/projects
Content-Type: application/json

{
"name": "TraceHub API",
"slug": "tracehub-api",
"description": "Main TraceHub API"
}

###

GET {{FCode.TraceHub.Api_HostAddress}}/api/projects
Accept: application/json

###

GET {{FCode.TraceHub.Api_HostAddress}}/api/projects/{{projectId}}
Accept: application/json

###

PUT {{FCode.TraceHub.Api_HostAddress}}/api/projects/{{projectId}}
Content-Type: application/json

{
"name": "TraceHub API",
"slug": "tracehub-api",
"description": "Updated TraceHub API"
}

###

DELETE {{FCode.TraceHub.Api_HostAddress}}/api/projects/{{projectId}}
22 changes: 21 additions & 1 deletion src/FCode.TraceHub.Api/Program.cs
Original file line number Diff line number Diff line change
@@ -1,17 +1,35 @@
using FCode.TraceHub.Api.ErrorHandling;
using FCode.TraceHub.Application.Projects;
using FCode.TraceHub.Infrastructure;


var builder = WebApplication.CreateBuilder(args);
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddScoped<IProjectService, ProjectService>();
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<ApiExceptionHandler>();

// Add services to the container.

builder.Services.AddControllers();
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
builder.Services.AddOpenApi(options =>
{
options.AddDocumentTransformer((document, _, _) =>
{
document.Info.Title = "FCODE TraceHub API";
document.Info.Version = "v1";
document.Info.Description =
"Project management and TraceHub platform API.";

return Task.CompletedTask;
});
});

var app = builder.Build();

app.UseExceptionHandler();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
Expand All @@ -26,3 +44,5 @@
app.MapHealthChecks("/health");

app.Run();

public partial class Program;
22 changes: 22 additions & 0 deletions src/FCode.TraceHub.Application/Projects/IProjectRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using FCode.TraceHub.Domain.Projects;

namespace FCode.TraceHub.Application.Projects;

public interface IProjectRepository
{
Task AddAsync(Project project, CancellationToken cancellationToken);

Task<Project?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken);

Task<IReadOnlyList<Project>> ListAsync(
CancellationToken cancellationToken);

Task<bool> SlugExistsAsync(
string slug,
Guid? excludedProjectId,
CancellationToken cancellationToken);

Task SaveChangesAsync(CancellationToken cancellationToken);
}
22 changes: 22 additions & 0 deletions src/FCode.TraceHub.Application/Projects/IProjectService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace FCode.TraceHub.Application.Projects;

public interface IProjectService
{
Task<ProjectResponse> CreateAsync(
CreateProjectRequest request,
CancellationToken cancellationToken);

Task<ProjectResponse> GetByIdAsync(
Guid id,
CancellationToken cancellationToken);

Task<IReadOnlyList<ProjectResponse>> ListAsync(
CancellationToken cancellationToken);

Task<ProjectResponse> UpdateAsync(
Guid id,
UpdateProjectRequest request,
CancellationToken cancellationToken);

Task ArchiveAsync(Guid id, CancellationToken cancellationToken);
}
20 changes: 20 additions & 0 deletions src/FCode.TraceHub.Application/Projects/ProjectContracts.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace FCode.TraceHub.Application.Projects;

public sealed record CreateProjectRequest(
string? Name,
string? Slug,
string? Description);

public sealed record UpdateProjectRequest(
string? Name,
string? Slug,
string? Description);

public sealed record ProjectResponse(
Guid Id,
string Name,
string Slug,
string? Description,
bool IsActive,
DateTime CreatedAtUtc,
DateTime? UpdatedAtUtc);
31 changes: 31 additions & 0 deletions src/FCode.TraceHub.Application/Projects/ProjectExceptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
namespace FCode.TraceHub.Application.Projects;

public sealed class ProjectValidationException(
IReadOnlyDictionary<string, string[]> errors) : Exception(
"One or more project values are invalid.")
{
public IReadOnlyDictionary<string, string[]> Errors { get; } = errors;
}

public sealed class ProjectNotFoundException(Guid projectId) : Exception(
$"Project '{projectId}' was not found.")
{
public Guid ProjectId { get; } = projectId;
}

public sealed class DuplicateProjectSlugException : Exception
{
public DuplicateProjectSlugException(string slug)
: base($"A project with slug '{slug}' already exists.")
{
Slug = slug;
}

public DuplicateProjectSlugException(string slug, Exception innerException)
: base($"A project with slug '{slug}' already exists.", innerException)
{
Slug = slug;
}

public string Slug { get; }
}
Loading