Skip to content

slyjeff/rest-resource-csharp

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

29 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SlySoft REST Resource

A C# library for building and consuming HATEOAS-based REST APIs. It provides a fluent server-side API for attaching hypermedia links to responses, serializers for JSON/XML/HTML output, ASP.NET Core formatters, and a client library for consuming those resources with typed accessors.

Packages

NuGet Package Description
SlySoft.RestResource Core library for building resources and links
SlySoft.RestResource.JSON.Utils JSON and XML serialization
SlySoft.RestResource.HTML.Utils HTML rendering for browsable APIs
SlySoft.RestResource.ASP.NET.Core.Utils ASP.NET Core output/input formatters
SlySoft.RestResource.Client.Utilities Client library for consuming resources

Server Side

The Resource Class

Inherit from Resource to add data and links to your API responses. Properties declared on the subclass are serialized as data fields; links are serialized separately under _links.

The Resource constructor accepts two optional parameters:

public Resource(object? sourceData = null, LinkSetup? linkSetup = null)

sourceData — an object whose properties are automatically copied to matching properties on the resource subclass (matched by name and compatible type). This lets you construct a resource directly from a domain object without manually assigning every field.

linkSetup — a LinkSetup delegate (delegate void LinkSetup(Resource resource)) that is invoked after auto-mapping completes, receiving the resource instance so you can add links. Because auto-mapping runs first, the already-populated properties are available inside the delegate.

The typical pattern is to expose both parameters through your subclass constructor and forward them to base:

public class UserResource(User user, LinkSetup? linkSetup = null) : Resource(user, linkSetup) {
    public string FirstName { get; set; } = "";
    public string LastName { get; set; } = "";
    public int Id { get; set; }
}

Construct and link in one step by passing a linkSetup lambda. The delegate receives a Resource, so cast it to your concrete type to access the mapped properties:

var resource = new UserResource(user, x => {
    if (x is not UserResource r) return;
    x.Get("self", $"/api/users/{r.Id}");
});

Or add links fluently after construction — both styles produce identical results:

var resource = new UserResource(user)
    .Get("self", $"/api/users/{user.Id}");

GET Links

Add a simple GET link:

var resource = new Resource()
    .Get("getUser", "/api/users/{id}", templated: true);

Add a GET link with query parameters using the untyped API:

var resource = new Resource()
    .Query("searchUsers", "/api/users")
        .Parameter("lastName")
        .Parameter("firstName", defaultValue: "")
    .EndQuery();

Add a GET link with typed query parameters mapped from a class:

public class UserSearchFilter {
    public string LastName { get; set; } = "";
    public string FirstName { get; set; } = "";
    public string Position { get; set; } = "";
    public int PageSize { get; set; } = 20;
}

var resource = new Resource()
    .Query<UserSearchFilter>("searchUsers", "/api/users")
        .Parameter(x => x.LastName)
        .Parameter(x => x.FirstName, defaultValue: "")
        .Parameter(x => x.PageSize, defaultValue: 20)
    .EndQuery();

Map all properties from a type automatically:

var resource = new Resource()
    .QueryWithAllParameters<UserSearchFilter>("searchUsers", "/api/users");

Exclude specific properties:

var resource = new Resource()
    .Query<UserSearchFilter>("searchUsers", "/api/users")
        .AllParameters()
        .Exclude(x => x.PageSize)
    .EndQuery();

POST Links

Add a POST link with body fields using the untyped API:

var resource = new Resource()
    .Post("createUser", "/api/users")
        .Field("lastName")
        .Field("firstName")
        .Field("position")
    .EndBody();

Add a POST link with typed body fields:

public enum UserPosition { Standard, Manager, Admin }

public class CreateUserRequest {
    public string LastName { get; set; } = "";
    public string FirstName { get; set; } = "";
    public UserPosition Position { get; set; } = UserPosition.Standard;
}

var resource = new Resource()
    .Post<CreateUserRequest>("createUser", "/api/users")
        .Field(x => x.LastName)
        .Field(x => x.FirstName)
        .Field(x => x.Position, defaultValue: UserPosition.Standard)
    .EndBody();

Enum properties automatically populate listOfValues with the enum members. Map all fields at once:

var resource = new Resource()
    .PostWithAllFields<CreateUserRequest>("createUser", "/api/users");

PUT and PATCH Links

Use the same fluent API as POST — just call .Put<T>() or .Patch<T>():

var resource = new UserResource(user)
    .Put<UpdateUserRequest>("updateUser", $"/api/users/{id}")
        .Field(x => x.FirstName)
        .Field(x => x.LastName)
        .Field(x => x.Position)
    .EndBody()
    .Patch<UpdateUserRequest>("patchUser", $"/api/users/{id}")
        .Field(x => x.Position)
    .EndBody();

For PATCH, the client library automatically sends only changed fields.


DELETE Links

var resource = new UserResource(user)
    .Delete("deleteUser", $"/api/users/{id}");

Timeouts

All link methods accept an optional timeout parameter (in seconds) that is communicated to clients:

var resource = new Resource()
    .Get("getLargeReport", "/api/reports/large", timeout: 300);

Chaining

All methods return the resource, so you can chain multiple links together:

var resource = new UserResource(user)
    .Get("self", $"/api/users/{id}")
    .Query<UserSearchFilter>("searchUsers", "/api/users")
        .AllParameters()
    .EndQuery()
    .Post<CreateUserRequest>("createUser", "/api/users")
        .AllFields()
    .EndBody()
    .Delete("deleteUser", $"/api/users/{id}");

Serialization

JSON

using SlySoft.RestResource.Serializers;

var json = resource.ToJson();

Output uses the application/slysoft+json content type and follows a HAL-like structure:

{
  "firstName": "Jane",
  "lastName": "Smith",
  "position": "Manager",
  "_links": {
    "self": {
      "href": "/api/users/42"
    },
    "searchUsers": {
      "href": "/api/users",
      "verb": "GET",
      "parameters": [
        { "name": "lastName" },
        { "name": "firstName", "defaultValue": "" }
      ]
    },
    "createUser": {
      "href": "/api/users",
      "verb": "POST",
      "parameters": [
        { "name": "lastName" },
        { "name": "firstName" },
        { "name": "position", "defaultValue": "Standard", "listOfValues": ["Standard", "Manager", "Admin"] }
      ]
    },
    "deleteUser": {
      "href": "/api/users/42",
      "verb": "DELETE"
    }
  }
}

XML

var xml = resource.ToSlySoftHalXml();

HTML

using SlySoft.RestResource.Html;

var html = resource.ToHtml();

Renders a browsable HTML page with styled forms for each link. Useful for human-readable API exploration.


ASP.NET Core Integration

Add the output formatters in Program.cs (or Startup.cs):

builder.Services.AddControllers(options => {
    options.OutputFormatters.Insert(0, new ResourceSlysoftJsonFormatter());
    options.OutputFormatters.Insert(1, new ResourceSlysoftXmlFormatter());
    options.OutputFormatters.Insert(2, new ResourceHtmlFormatter());
});

Then return Resource objects from your controllers — content negotiation selects the right format automatically:

[ApiController]
[Route("api/users")]
public class UsersController : ControllerBase {
    [HttpGet("{id}")]
    public UserResource GetUser(int id) {
        var user = _repository.GetById(id);
        return new UserResource(user)
            .Get("self", $"/api/users/{id}")
            .Delete("deleteUser", $"/api/users/{id}");
    }

    [HttpGet]
    public Resource SearchUsers([FromQuery] UserSearchFilter filter) {
        var users = _repository.Search(filter);
        return new UserListResource(users)
            .Query<UserSearchFilter>("searchUsers", "/api/users")
                .AllParameters()
            .EndQuery()
            .Post<CreateUserRequest>("createUser", "/api/users")
                .AllFields()
            .EndBody();
    }
}

Client Side

Setup

using SlySoft.RestResource.Client;

var client = new RestClient("https://api.example.com");

Optional configuration:

// Set authorization
client.SetAuthorizationHeaderValue("Bearer", token);

// Set accept header for additional content types
client.SetDefaultAcceptHeader("application/slysoft+json", "application/json");

// Change default timeout (seconds)
client.DefaultTimeout = 60;

You can also pass an existing HttpClient if you need to configure it yourself (e.g., for testing):

var client = new RestClient(httpClient);

Fetching Resources

Retrieve a resource as a ClientResource (untyped):

var resource = client.Get<ClientResource>("/api/users/42");

// Access data
var firstName = resource.Data["firstName"]?.ToString();

// Access links
var deleteLink = resource.Links.FirstOrDefault(l => l.Name == "deleteUser");

Or as a raw string:

var json = client.Get<string>("/api/users/42");

Async versions are available for all calls:

var resource = await client.GetAsync<ClientResource>("/api/users/42");

Typed Accessors

The recommended approach is to define an interface and let the client generate a typed accessor automatically.

Define interfaces matching the structure of the resource and its links:

public interface IUser {
    string FirstName { get; set; }
    string LastName { get; set; }
    string Position { get; set; }
}

public interface IUserResource : IUser {
    // Link with no parameters — uses current property values as the body
    void UpdateUser();

    // Link called with named parameters
    IUserListResource SearchUsers(string lastName, string firstName);

    // Link called with an anonymous object (useful for dynamic scenarios)
    IUserListResource SearchUsers(object searchParameters);

    // Async variants
    Task<IUserListResource> SearchUsersAsync(string lastName, string firstName);

    // Link that returns nothing
    void DeleteUser();
    Task DeleteUserAsync();
}

public interface IUserListResource {
    IList<IUser> Users { get; }
}

Fetch and use the accessor:

var userResource = client.Get<IUserResource>("/api/users/42");

// Read properties
Console.WriteLine(userResource.FirstName);

// Call a link with named parameters
var smiths = userResource.SearchUsers(lastName: "Smith", firstName: "");

// Call a link with an anonymous object
var results = userResource.SearchUsers(new { lastName = "Jones" });

// Async
var allUsersAsync = await userResource.SearchUsersAsync("Smith", "");

Method parameters are matched to link parameter names by convention (case-insensitive). Templated URL parameters (e.g., {id}) are resolved from the method arguments before the rest are sent as query parameters or body fields.


Change Tracking (Editable Accessors)

Inherit IEditableAccessor on your accessor interface to get change tracking and PATCH support:

public interface IUserResource : IEditableAccessor {
    string FirstName { get; set; }
    string LastName { get; set; }

    // PUT — sends all fields
    void UpdateUser();

    // PATCH — automatically sends only changed fields
    void PatchUser();
}
var user = client.Get<IUserResource>("/api/users/42");

user.FirstName = "Jane";   // IsChanged becomes true

// Calling a PATCH link auto-sends only changed fields
user.PatchUser();

// Revert all changes
user.RejectChanges();

IEditableAccessor also implements INotifyPropertyChanged, making it suitable for use in data-binding scenarios.


Checking Link Availability

Use [LinkCheck] on bool properties to check whether a link exists in the response (useful for conditional UI):

public interface IUserResource {
    string FirstName { get; }

    // Checks for a link named "canDeleteUser" by convention ("Can" + PropertyName)
    [LinkCheck]
    bool CanDeleteUser { get; }

    // Checks for a specific link name
    [LinkCheck("deleteUser")]
    bool CanDelete { get; }
}
var user = client.Get<IUserResource>("/api/users/42");

if (user.CanDeleteUser) {
    // Show delete button
}

Inspecting Parameter Metadata

Use [ParameterInfo] to expose parameter metadata (default values, list of values, type) to callers:

public interface IUserResource {
    [ParameterInfo("searchUsers", "position")]
    IParameterInfo PositionParameterInfo { get; }
}
var user = client.Get<IUserResource>("/api");
var positionInfo = user.PositionParameterInfo;

Console.WriteLine(positionInfo.DefaultValue);                        // "Standard"
Console.WriteLine(string.Join(", ", positionInfo.ListOfValues));     // "Standard, Manager, Admin"

Low-Level Calls

Inherit IResourceAccessor when you need to call links dynamically by name without defining a method on the interface:

public interface IUserResource : IResourceAccessor {
    string FirstName { get; }
}

var user = client.Get<IUserResource>("/api/users/42");

// Call any link by name with a parameter dictionary
await user.CallRestLinkAsync("deleteUser", new Dictionary<string, object?>());

var results = user.CallRestLink<IUserListResource>("searchUsers", new Dictionary<string, object?> {
    { "lastName", "Smith" }
});

Error Handling

All client exceptions derive from RestResourceClientException:

Exception When thrown
RestCallException Network or transport error
CallLinkException Named link not found or link call failed
CreateAccessorException Accessor interface could not be created
ResponseErrorCodeException Server returned a non-success HTTP status

ResponseErrorCodeException exposes the status code and lets you deserialize the error body:

try {
    var user = client.Get<IUserResource>("/api/users/999");
} catch (ResponseErrorCodeException ex) {
    Console.WriteLine(ex.StatusCode);          // e.g., HttpStatusCode.NotFound

    // Deserialize error body to a typed object
    var error = ex.Content<ApiErrorResponse>();
    Console.WriteLine(error?.Message);

    // Or get it as a string
    var raw = ex.Content<string>();
}

License

Apache 2.0 — see LICENSE.txt.

About

A C# Library for adding links to resources

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors