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.
| 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 |
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}");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();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");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.
var resource = new UserResource(user)
.Delete("deleteUser", $"/api/users/{id}");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);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}");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"
}
}
}var xml = resource.ToSlySoftHalXml();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.
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();
}
}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);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");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.
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.
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
}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"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" }
});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>();
}Apache 2.0 — see LICENSE.txt.