Sub-Resources

We've been working with a single resource (the employee resource) up until now. In the real world, we often need to work with other resources.

Let's talk through some design considerations first and foremost.

Nested Resources

One of the most common ways to query sub-resources is to use nested routes. This is a pattern that is well-supported by OpenAPI and by most API clients.

Let's create a new class to represent employee benefits and an enum to represent the type of benefit. Copy these into the Employee.cs file for now.

public class EmployeeBenefits
{
    public int Id { get; set; }
    public int EmployeeId { get; set; }
    public BenefitType BenefitType { get; set; }
    public decimal Cost { get; set; }
}

public enum BenefitType
{
    Health,
    Dental,
    Vision
}

We can also extend our Employee class to include a list of benefits.

public class Employee
{
    public int Id { get; set; }
    //...
    public List<EmployeeBenefits> Benefits { get; set; } = new List<EmployeeBenefits>();
}

We have a couple common ways we can handle querying these sub-resources.

  1. Include them with the original object - good for a small number of objects in your resource (think a list of addresses on a customer for example).
  2. Query them separately - good for a large number of objects in your resource (think a list of orders on a customer for example. How many orders have you made on Amazon? Exactly.)

Option 1: Include them with the original object

This is probably the most common approach. We've already done this a bit with the Employee object that we've been using.

You can alter the GetEmployeeResponse class to include the benefits:

public class GetEmployeeResponse
{
    public required string FirstName { get; set; }
    public required string LastName { get; set; }

    public string? Address1 { get; set; }
    public string? Address2 { get; set; }
    public string? City { get; set; }
    public string? State { get; set; }
    public string? ZipCode { get; set; }
    public string? PhoneNumber { get; set; }
    public string? Email { get; set; }

    public required List<GetEmployeeResponseEmployeeBenefit> Benefits { get; set; }
}

public class GetEmployeeResponseEmployeeBenefit
{
    public int Id { get; set; }
    public int EmployeeId { get; set; }
    public BenefitType BenefitType { get; set; }
    public decimal Cost { get; set; }
}

Then alter your Get method to return it:

/// <summary>
/// Get all employees.
/// </summary>
/// <returns>An array of all employees.</returns>
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<GetEmployeeResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult GetAllEmployees()
{
    var employees = _repository.GetAll().Select(employee => new GetEmployeeResponse
    {
        FirstName = employee.FirstName,
        LastName = employee.LastName,
        Address1 = employee.Address1,
        Address2 = employee.Address2,
        City = employee.City,
        State = employee.State,
        ZipCode = employee.ZipCode,
        PhoneNumber = employee.PhoneNumber,
        Email = employee.Email,
        Benefits = employee.Benefits.Select(benefit => new GetEmployeeResponseEmployeeBenefit
        {
            Id = benefit.Id,
            EmployeeId = benefit.EmployeeId,
            BenefitType = benefit.BenefitType,
            Cost = benefit.Cost
        }).ToList()
    });

    return Ok(employees);
}

/// <summary>
/// Gets an employee by ID.
/// </summary>
/// <param name="id">The ID of the employee.</param>
/// <returns>The single employee record.</returns>
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(GetEmployeeResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult GetEmployeeById(int id)
{
    var employee = _repository.GetById(id);
    if (employee == null)
    {
        return NotFound();
    }

    var employeeResponse = new GetEmployeeResponse
    {
        FirstName = employee.FirstName,
        LastName = employee.LastName,
        Address1 = employee.Address1,
        Address2 = employee.Address2,
        City = employee.City,
        State = employee.State,
        ZipCode = employee.ZipCode,
        PhoneNumber = employee.PhoneNumber,
        Email = employee.Email,
        Benefits = employee.Benefits.Select(benefit => new GetEmployeeResponseEmployeeBenefit
        {
            Id = benefit.Id,
            EmployeeId = benefit.EmployeeId,
            BenefitType = benefit.BenefitType,
            Cost = benefit.Cost
        }).ToList()
    };

    return Ok(employeeResponse);
}

You know something? Let's make a common method to convert an Employee object to a GetEmployeeResponse object.

private GetEmployeeResponse EmployeeToGetEmployeeResponse(Employee employee)
{
    return new GetEmployeeResponse
    {
        FirstName = employee.FirstName,
        LastName = employee.LastName,
        Address1 = employee.Address1,
        Address2 = employee.Address2,
        City = employee.City,
        State = employee.State,
        ZipCode = employee.ZipCode,
        PhoneNumber = employee.PhoneNumber,
        Email = employee.Email,
        Benefits = employee.Benefits.Select(benefit => new GetEmployeeResponseEmployeeBenefit
        {
            Id = benefit.Id,
            EmployeeId = benefit.EmployeeId,
            BenefitType = benefit.BenefitType,
            Cost = benefit.Cost
        }).ToList()
    };
}

Removes a bit of duplication, yay!

/// <summary>
/// Get all employees.
/// </summary>
/// <returns>An array of all employees.</returns>
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<GetEmployeeResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult GetAllEmployees()
{
    var employees = _repository.GetAll().Select(EmployeeToGetEmployeeResponse);

    return Ok(employees);
}

/// <summary>
/// Gets an employee by ID.
/// </summary>
/// <param name="id">The ID of the employee.</param>
/// <returns>The single employee record.</returns>
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(GetEmployeeResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult GetEmployeeById(int id)
{
    var employee = _repository.GetById(id);
    if (employee == null)
    {
        return NotFound();
    }

    var employeeResponse = EmployeeToGetEmployeeResponse(employee);
    return Ok(employeeResponse);
}

This works okay to get the objects, but it gets more complex when we start to think about adding/removing sub-resources with our existing endpoints.

  1. Do we simply pass in the sub-resource data for ALL sub-objects in the parent object's endpoint?
  2. Do we create new endpoints to handle the sub-resource data?
  3. How do we handle updating?

I'd say - let's answer these questions in more detail once we have a real data source. Outside the context of a real data source, we'd end up writing a bunch of code we're gonna throw away anyways.

However, there's another approach we can take that we should talk about.

Option 2: Query them separately

This is the other common way to deal with sub-resources: query them separately.

You can do this by creating a separate endpoint for the sub-resource.

/// <summary>
/// Gets the benefits for an employee.
/// </summary>
/// <param name="employeeId">The ID to get the benefits for.</param>
/// <returns>The benefits for that employee.</returns>
[HttpGet("{employeeId}/benefits")]
[ProducesResponseType(typeof(IEnumerable<GetEmployeeResponseEmployeeBenefit>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult GetBenefitsForEmployee(int employeeId)
{
    var employee = _repository.GetById(employeeId);
    if (employee == null)
    {
        return NotFound();
    }
    return Ok(employee.Benefits);
}

Note that we can browse to this endpoint to get the benefits for an employee. One last thing - we should create a factory method for the benefits response object so we don't break the rule of returning entities.

private static GetEmployeeResponse EmployeeToGetEmployeeResponse(Employee employee)
{
    return new GetEmployeeResponse
    {
        FirstName = employee.FirstName,
        LastName = employee.LastName,
        Address1 = employee.Address1,
        Address2 = employee.Address2,
        City = employee.City,
        State = employee.State,
        ZipCode = employee.ZipCode,
        PhoneNumber = employee.PhoneNumber,
        Email = employee.Email,
        Benefits = employee.Benefits.Select(BenefitToBenefitResponse).ToList()
    };
}

private static GetEmployeeResponseEmployeeBenefit BenefitToBenefitResponse(EmployeeBenefits benefit)
{
    return new GetEmployeeResponseEmployeeBenefit
    {
        Id = benefit.Id,
        EmployeeId = benefit.EmployeeId,
        BenefitType = benefit.BenefitType,
        Cost = benefit.Cost
    };
}

Looks good, now we can use that method.

/// <summary>
/// Gets the benefits for an employee.
/// </summary>
/// <param name="employeeId">The ID to get the benefits for.</param>
/// <returns>The benefits for that employee.</returns>
[HttpGet("{employeeId}/benefits")]
[ProducesResponseType(typeof(IEnumerable<GetEmployeeResponseEmployeeBenefit>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult GetBenefitsForEmployee(int employeeId)
{
    var employee = _repository.GetById(employeeId);
    if (employee == null)
    {
        return NotFound();
    }
    return Ok(employee.Benefits.Select(BenefitToBenefitResponse));
}

We can also add a test for it:

public BasicTests(WebApplicationFactory<Program> factory)
{
    _factory = factory;

    var repo = _factory.Services.GetRequiredService<IRepository<Employee>>();
    repo.Create(new Employee { 
        FirstName = "John", 
        LastName = "Doe",
        Address1 = "123 Main St",
        Benefits = new List<EmployeeBenefits>
        {
            new EmployeeBenefits { BenefitType = BenefitType.Health, Cost = 100 },
            new EmployeeBenefits { BenefitType = BenefitType.Dental, Cost = 50 }
        }
    });
    
    _employeeId = repo.GetAll().First().Id;
}

[Fact]
public async Task GetBenefitsForEmployee_ReturnsOkResult()
{
    // Act
    var client = _factory.CreateClient();
    var response = await client.GetAsync($"/employees/{_employeeId}/benefits");

    // Assert
    response.EnsureSuccessStatusCode();
    
    var benefits = await response.Content.ReadFromJsonAsync<IEnumerable<GetEmployeeResponseEmployeeBenefit>>();
    Assert.Equal(2, benefits.Count());
}

Run the tests - everything should be green!