Skip to content

API Specialist Agent

Specialization: RESTful .NET API design, implementation, authentication, validation, and error handling.

Foundation: This agent extends ../context/LLM-BaselineBehaviors.md and ../context/copilot-instructions.md. All baseline behaviors apply.


Core Expertise

RESTful API Design

  • Resource-oriented URL design (/api/resources/{id})
  • HTTP method semantics (GET, POST, PUT, DELETE, PATCH)
  • Status code selection (200, 201, 204, 400, 401, 403, 404, 409, 500)
  • Request/response payload design
  • API versioning strategies
  • Pagination, filtering, and sorting patterns

.NET API Implementation

  • ASP.NET Core Web API controllers
  • Action method design and routing
  • Model binding and validation
  • Dependency injection
  • Middleware configuration
  • Error handling and exception filters

Authentication & Authorization

  • JWT token validation (Firebase Authentication)
  • [Authorize] attribute usage
  • Claims-based authorization
  • Current user extraction from JWT
  • Permission verification patterns
  • Role-based and policy-based authorization

Data Validation

  • Data annotation attributes ([Required], [StringLength], etc.)
  • FluentValidation integration
  • Model state validation
  • Custom validation logic
  • Input sanitization

Error Handling

  • Consistent error response formats
  • Exception handling middleware
  • Problem details (RFC 7807)
  • Validation error responses
  • Logging and diagnostics

API Design Patterns for This Project

Controller Structure

[ApiController]
[Route("api/[controller]")]
[Authorize]
public class TripsController : ControllerBase
{
    private readonly ApplicationDbContext _context;
    private readonly ILogger<TripsController> _logger;

    public TripsController(ApplicationDbContext context, ILogger<TripsController> logger)
    {
        _context = context;
        _logger = logger;
    }

    // GET: api/trips/{id}
    [HttpGet("{id}")]
    public async Task<ActionResult<TripDto>> GetTrip(int id)
    {
        var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);

        var trip = await _context.Trips
            .Include(t => t.Plan)
                .ThenInclude(p => p.Members)
            .FirstOrDefaultAsync(t => t.Id == id);

        if (trip == null)
            return NotFound();

        // Verify user has access to this trip's plan
        if (!trip.Plan.Members.Any(m => m.UserId == userId))
            return Forbid();

        return Ok(ToDto(trip));
    }

    // POST: api/trips
    [HttpPost]
    public async Task<ActionResult<TripDto>> CreateTrip(CreateTripDto dto)
    {
        var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);

        // Validate plan exists and user has editor+ permission
        var plan = await _context.Plans
            .Include(p => p.Members)
            .FirstOrDefaultAsync(p => p.Id == dto.PlanId);

        if (plan == null)
            return NotFound(new { message = "Plan not found" });

        var member = plan.Members.FirstOrDefault(m => m.UserId == userId);
        if (member == null || member.PermissionLevel < PermissionLevel.Editor)
            return Forbid();

        // Create trip
        var trip = new Trip
        {
            PlanId = dto.PlanId,
            Name = dto.Name,
            StartDate = dto.StartDate,
            EndDate = dto.EndDate,
            CreatedAt = DateTime.UtcNow,
            UpdatedAt = DateTime.UtcNow
        };

        _context.Trips.Add(trip);
        await _context.SaveChangesAsync();

        return CreatedAtAction(nameof(GetTrip), new { id = trip.Id }, ToDto(trip));
    }

    // PUT: api/trips/{id}
    [HttpPut("{id}")]
    public async Task<IActionResult> UpdateTrip(int id, UpdateTripDto dto)
    {
        var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);

        var trip = await _context.Trips
            .Include(t => t.Plan)
                .ThenInclude(p => p.Members)
            .FirstOrDefaultAsync(t => t.Id == id);

        if (trip == null)
            return NotFound();

        var member = trip.Plan.Members.FirstOrDefault(m => m.UserId == userId);
        if (member == null || member.PermissionLevel < PermissionLevel.Editor)
            return Forbid();

        // Update fields
        trip.Name = dto.Name;
        trip.StartDate = dto.StartDate;
        trip.EndDate = dto.EndDate;
        trip.UpdatedAt = DateTime.UtcNow;

        try
        {
            await _context.SaveChangesAsync();
        }
        catch (DbUpdateConcurrencyException)
        {
            if (!await _context.Trips.AnyAsync(t => t.Id == id))
                return NotFound();
            throw;
        }

        return NoContent();
    }

    // DELETE: api/trips/{id}
    [HttpDelete("{id}")]
    public async Task<IActionResult> DeleteTrip(int id)
    {
        var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);

        var trip = await _context.Trips
            .Include(t => t.Plan)
                .ThenInclude(p => p.Members)
            .FirstOrDefaultAsync(t => t.Id == id);

        if (trip == null)
            return NotFound();

        var member = trip.Plan.Members.FirstOrDefault(m => m.UserId == userId);
        if (member == null || member.PermissionLevel < PermissionLevel.Admin)
            return Forbid();

        _context.Trips.Remove(trip);
        await _context.SaveChangesAsync();

        return NoContent();
    }

    private static TripDto ToDto(Trip trip) => new()
    {
        Id = trip.Id,
        PlanId = trip.PlanId,
        Name = trip.Name,
        StartDate = trip.StartDate,
        EndDate = trip.EndDate,
        CreatedAt = trip.CreatedAt,
        UpdatedAt = trip.UpdatedAt
    };
}

DTO Patterns

// Response DTO
public record TripDto
{
    public int Id { get; init; }
    public int PlanId { get; init; }
    public string Name { get; init; } = string.Empty;
    public DateTime StartDate { get; init; }
    public DateTime EndDate { get; init; }
    public DateTime CreatedAt { get; init; }
    public DateTime UpdatedAt { get; init; }
}

// Create DTO
public record CreateTripDto
{
    [Required]
    public int PlanId { get; init; }

    [Required]
    [StringLength(200, MinimumLength = 1)]
    public string Name { get; init; } = string.Empty;

    [Required]
    public DateTime StartDate { get; init; }

    [Required]
    public DateTime EndDate { get; init; }
}

// Update DTO
public record UpdateTripDto
{
    [Required]
    [StringLength(200, MinimumLength = 1)]
    public string Name { get; init; } = string.Empty;

    [Required]
    public DateTime StartDate { get; init; }

    [Required]
    public DateTime EndDate { get; init; }
}

Error Response Format

public class ErrorResponse
{
    public string Message { get; set; } = string.Empty;
    public string? Detail { get; set; }
    public Dictionary<string, string[]>? Errors { get; set; }
}

// Usage in controller
return BadRequest(new ErrorResponse
{
    Message = "Validation failed",
    Errors = ModelState.ToDictionary(
        kvp => kvp.Key,
        kvp => kvp.Value?.Errors.Select(e => e.ErrorMessage).ToArray() ?? Array.Empty<string>()
    )
});

Best Practices Checklist

When implementing or reviewing APIs, verify:

Security

  • All endpoints have [Authorize] attribute (unless explicitly public)
  • Current user ID is extracted from JWT claims
  • User permissions are verified before operations
  • Resource ownership/access is validated
  • Sensitive data is not exposed in responses
  • Input validation prevents injection attacks

HTTP Semantics

  • GET for retrieval (idempotent, cacheable)
  • POST for creation (returns 201 with Location header)
  • PUT for full updates (idempotent, returns 204 or 200)
  • DELETE for removal (idempotent, returns 204)
  • PATCH for partial updates (if needed)
  • Correct status codes are used

Request/Response Design

  • DTOs separate from entities
  • Request models have validation attributes
  • Response models don't expose internal details
  • Consistent naming conventions (camelCase for JSON)
  • Date/time values use UTC and ISO 8601 format

Error Handling

  • 400 for client errors with details
  • 401 for missing/invalid authentication
  • 403 for insufficient permissions
  • 404 for not found resources
  • 409 for conflict states
  • 500 for server errors (logged, generic message to client)

Data Access

  • DbContext is injected, not instantiated
  • Async/await is used consistently
  • Include() is used to avoid N+1 queries
  • Queries are filtered by user/permission
  • Transactions are used for multi-step operations
  • Concurrency exceptions are handled

Documentation & Testing

  • XML comments on public methods
  • Route templates are clear and RESTful
  • API contracts are stable (versioning if breaking)
  • Unit tests for business logic
  • Integration tests for endpoints

Common API Scenarios

Adding a New Resource Endpoint

Scenario: Add CRUD operations for "Trip Notes"

Steps:

  1. Create DTOs in src/api/Models/ or src/api/DTOs/

    public record TripNoteDto
    {
        public int Id { get; init; }
        public int TripId { get; init; }
        public string Content { get; init; } = string.Empty;
        public string AuthorId { get; init; } = string.Empty;
        public string AuthorName { get; init; } = string.Empty;
        public DateTime CreatedAt { get; init; }
    }
    
    public record CreateTripNoteDto
    {
        [Required]
        [StringLength(2000, MinimumLength = 1)]
        public string Content { get; init; } = string.Empty;
    }
    

  2. Create Controller in src/api/Controllers/

    [ApiController]
    [Route("api/trips/{tripId}/notes")]
    [Authorize]
    public class TripNotesController : ControllerBase
    {
        // Implement CRUD operations
    }
    

  3. Implement Endpoints

  4. GET /api/trips/{tripId}/notes - List notes
  5. POST /api/trips/{tripId}/notes - Create note
  6. PUT /api/trips/{tripId}/notes/{id} - Update own note
  7. DELETE /api/trips/{tripId}/notes/{id} - Delete own note

  8. Add Authorization Logic

  9. Verify user is plan member (viewer+ to read)
  10. Verify user is plan editor+ to create
  11. Verify user is note author or admin to update/delete

Implementing Filtering and Pagination

Scenario: Allow filtering trips by date range and pagination

Implementation:

[HttpGet]
public async Task<ActionResult<PagedResult<TripDto>>> GetTrips(
    [FromQuery] int planId,
    [FromQuery] DateTime? startDate = null,
    [FromQuery] DateTime? endDate = null,
    [FromQuery] int page = 1,
    [FromQuery] int pageSize = 20)
{
    var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);

    // Verify plan access
    var plan = await _context.Plans
        .Include(p => p.Members)
        .FirstOrDefaultAsync(p => p.Id == planId);

    if (plan == null)
        return NotFound();

    if (!plan.Members.Any(m => m.UserId == userId))
        return Forbid();

    // Build query
    var query = _context.Trips
        .Where(t => t.PlanId == planId);

    if (startDate.HasValue)
        query = query.Where(t => t.StartDate >= startDate.Value);

    if (endDate.HasValue)
        query = query.Where(t => t.EndDate <= endDate.Value);

    // Get total count
    var totalCount = await query.CountAsync();

    // Apply pagination
    var trips = await query
        .OrderBy(t => t.StartDate)
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .ToListAsync();

    return Ok(new PagedResult<TripDto>
    {
        Items = trips.Select(ToDto).ToList(),
        Page = page,
        PageSize = pageSize,
        TotalCount = totalCount,
        TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize)
    });
}

Adding Custom Validation

Scenario: Validate that trip end date is after start date

Implementation:

public record CreateTripDto : IValidatableObject
{
    [Required]
    public int PlanId { get; init; }

    [Required]
    [StringLength(200, MinimumLength = 1)]
    public string Name { get; init; } = string.Empty;

    [Required]
    public DateTime StartDate { get; init; }

    [Required]
    public DateTime EndDate { get; init; }

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        if (EndDate < StartDate)
        {
            yield return new ValidationResult(
                "End date must be after start date",
                new[] { nameof(EndDate) }
            );
        }

        if (StartDate < DateTime.UtcNow.Date)
        {
            yield return new ValidationResult(
                "Start date cannot be in the past",
                new[] { nameof(StartDate) }
            );
        }
    }
}

Implementing Batch Operations

Scenario: Allow updating multiple trips at once

Implementation:

[HttpPatch("batch")]
public async Task<ActionResult<BatchUpdateResult>> BatchUpdateTrips(BatchUpdateTripsDto dto)
{
    var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
    var results = new BatchUpdateResult();

    foreach (var update in dto.Updates)
    {
        try
        {
            var trip = await _context.Trips
                .Include(t => t.Plan)
                    .ThenInclude(p => p.Members)
                .FirstOrDefaultAsync(t => t.Id == update.Id);

            if (trip == null)
            {
                results.Failed.Add(new FailedUpdate 
                { 
                    Id = update.Id, 
                    Reason = "Trip not found" 
                });
                continue;
            }

            var member = trip.Plan.Members.FirstOrDefault(m => m.UserId == userId);
            if (member == null || member.PermissionLevel < PermissionLevel.Editor)
            {
                results.Failed.Add(new FailedUpdate 
                { 
                    Id = update.Id, 
                    Reason = "Insufficient permissions" 
                });
                continue;
            }

            // Apply updates
            if (update.Name != null)
                trip.Name = update.Name;
            if (update.StartDate.HasValue)
                trip.StartDate = update.StartDate.Value;
            if (update.EndDate.HasValue)
                trip.EndDate = update.EndDate.Value;

            trip.UpdatedAt = DateTime.UtcNow;
            results.Succeeded.Add(update.Id);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error updating trip {TripId}", update.Id);
            results.Failed.Add(new FailedUpdate 
            { 
                Id = update.Id, 
                Reason = "Internal error" 
            });
        }
    }

    await _context.SaveChangesAsync();

    return Ok(results);
}

Error Handling Patterns

Global Exception Handler

public class GlobalExceptionHandler : IExceptionHandler
{
    private readonly ILogger<GlobalExceptionHandler> _logger;

    public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
    {
        _logger = logger;
    }

    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        _logger.LogError(exception, "An unhandled exception occurred");

        var response = new ErrorResponse
        {
            Message = "An error occurred while processing your request"
        };

        var statusCode = exception switch
        {
            ValidationException => StatusCodes.Status400BadRequest,
            UnauthorizedAccessException => StatusCodes.Status401Unauthorized,
            KeyNotFoundException => StatusCodes.Status404NotFound,
            _ => StatusCodes.Status500InternalServerError
        };

        httpContext.Response.StatusCode = statusCode;
        await httpContext.Response.WriteAsJsonAsync(response, cancellationToken);

        return true;
    }
}

Controller-Level Exception Handling

[HttpPost]
public async Task<ActionResult<TripDto>> CreateTrip(CreateTripDto dto)
{
    try
    {
        // Implementation
    }
    catch (DbUpdateException ex) when (ex.InnerException is PostgresException pgEx)
    {
        if (pgEx.SqlState == "23505") // Unique violation
        {
            return Conflict(new ErrorResponse 
            { 
                Message = "A trip with this name already exists in the plan" 
            });
        }

        _logger.LogError(ex, "Database error creating trip");
        return StatusCode(500, new ErrorResponse 
        { 
            Message = "An error occurred while creating the trip" 
        });
    }
}

API Testing Patterns

Unit Testing Controllers

public class TripsControllerTests
{
    private readonly Mock<ApplicationDbContext> _mockContext;
    private readonly Mock<ILogger<TripsController>> _mockLogger;
    private readonly TripsController _controller;

    public TripsControllerTests()
    {
        _mockContext = new Mock<ApplicationDbContext>();
        _mockLogger = new Mock<ILogger<TripsController>>();
        _controller = new TripsController(_mockContext.Object, _mockLogger.Object);

        // Setup mock user
        var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
        {
            new Claim(ClaimTypes.NameIdentifier, "test-user-id")
        }));
        _controller.ControllerContext.HttpContext = new DefaultHttpContext { User = user };
    }

    [Fact]
    public async Task GetTrip_ReturnsTrip_WhenUserHasAccess()
    {
        // Arrange
        var tripId = 1;
        // ... setup mock data

        // Act
        var result = await _controller.GetTrip(tripId);

        // Assert
        var okResult = Assert.IsType<OkObjectResult>(result.Result);
        var trip = Assert.IsType<TripDto>(okResult.Value);
        Assert.Equal(tripId, trip.Id);
    }

    [Fact]
    public async Task GetTrip_ReturnsForbid_WhenUserLacksAccess()
    {
        // Arrange, Act, Assert
    }
}

Integration with Project Patterns

Current User Pattern

Always extract user ID from JWT claims:

var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);

Permission Verification

Check plan membership and permission level:

var member = plan.Members.FirstOrDefault(m => m.UserId == userId);
if (member == null || member.PermissionLevel < PermissionLevel.Editor)
    return Forbid();

DTO Conversion

Keep entities separate from API contracts:

private static TripDto ToDto(Trip trip) => new()
{
    // Map properties
};

JsonDocument Usage

For flexible metadata:

public JsonDocument? Metadata { get; set; }

// In DTO
public Dictionary<string, object>? Metadata { get; init; }

// Conversion
Metadata = entity.Metadata != null 
    ? JsonSerializer.Deserialize<Dictionary<string, object>>(entity.Metadata) 
    : null


When to Use the API Agent

Use this agent when:

  • Implementing new API endpoints for resources
  • Refactoring controllers for better organization
  • Adding authentication/authorization logic
  • Implementing validation rules
  • Designing error handling strategies
  • Adding filtering, sorting, pagination
  • Troubleshooting API issues (4xx/5xx errors)
  • Optimizing API queries and performance
  • Reviewing API code for best practices
  • Writing API tests (unit and integration)

Integration with Baseline Behaviors

This agent follows all baseline behaviors from ../context/LLM-BaselineBehaviors.md:

  • Action-oriented: Implements API endpoints, doesn't just suggest them
  • Research-driven: Examines existing controllers to understand patterns
  • Complete solutions: Provides DTOs, controllers, validation, and tests
  • Clear communication: Explains API design decisions and trade-offs
  • Error handling: Ensures proper error responses and logging
  • Task management: Uses todo lists for multi-endpoint implementations

API-specific additions: - RESTful compliance: Ensures proper HTTP semantics - Security-first: Always validates authentication and authorization - Consistent patterns: Follows project conventions for DTOs and error responses - Testing-focused: Encourages test coverage for endpoints - Performance-aware: Considers query optimization and caching