Endpoints

Table of contents

If you’re migrating from an older version, the legacy attributes (HttpGetHypermediaObject, HttpPostHypermediaAction, etc.) are still supported but deprecated. The included Roslyn analyzers (RY0010–RY0015) will suggest migration to the new attributes.

The included SirenFormatter will build required links to other routes. At startup all routes attributed with the following will be placed in an internal register:

  • HypermediaObjectEndpoint<THto> — for GET routes returning HTOs
  • HypermediaActionEndpoint<THto>(nameof(...)) — for POST/PUT/PATCH/DELETE action routes
  • HypermediaActionParameterInfoEndpoint<TParam> — for action parameter schema routes

This means that for every HTO there must be a route with matching type.

Returning a Siren document with Ok()

Use Ok(myHto) to return a full Siren document. The formatter serializes the HTO with all resolved links, entities, and actions.

[HttpGet(""), HypermediaObjectEndpoint<HypermediaCustomersRootHto>]
public ActionResult GetRootDocument()
{
    return Ok(customersRoot);
}

Returning a Location header with this.Created()

Action endpoints typically don’t return a Siren document. Instead they perform an operation and return HTTP 201 with a Location header pointing to the created or resulting resource. The action endpoint references the HTO that owns the action and names the action property:

[HttpPost("CreateCustomer"),
 HypermediaActionEndpoint<HypermediaCustomersRootHto>(nameof(HypermediaCustomersRootHto.CreateCustomer))]
public async Task<ActionResult> NewCustomerAction(CreateCustomerParameters createCustomerParameters)
{
    if (createCustomerParameters == null)
    {
        return this.Problem(ProblemJsonBuilder.CreateBadParameters());
    }

    var createdCustomer = await CreateCustomer(createCustomerParameters);

    // Will create a Location header with a URI to the result.
    return this.Created(Link.To(createdCustomer));
}

this.Created() returns HTTP 201 with a Location header pointing to the created or resulting resource. The client follows this URL instead of building it — this is central to the hypermedia approach. There are several variants:

// Link from an existing HTO instance
return this.Created(Link.To(createdCustomer));

// Link by key — no instance needed
return this.Created(Link.ByKey(new HypermediaCarHto.Key(carId, brand)));

// Link to a query result
return this.Created(Link.ByQuery<HypermediaCustomerQueryResultHto>(query));

When using CORS, you must expose the Location header so browser clients can read it: .WithExposedHeaders("Location")

Siren specifies that to trigger an action an array of parameters should be posted to the action route. To avoid wrapping parameters in an array class there is the SingleParameterBinder for convenience.

A valid JSON for this route would look like this:

[{"CreateCustomerParameters":
   {
     "Name":"Hans Schmid"
   }
}]

The parameter binder also allows to pass a parameter object without the wrapping array:

{"CreateCustomerParameters":
    {
      "Name":"Hans Schmid"
    }
}

Action parameter schemas

By default, RESTyard automatically generates schema endpoints for all action parameter types (implementing IHypermediaActionParameter). These are added to the Siren fields object as class so clients can discover the expected parameter structure. This is controlled by the AutoDeliverJsonSchemaForActionParameterTypes option (see Configuration).

Use HypermediaActionParameterInfoEndpoint<T> only when you need to override the auto-generated schema with custom logic:

[HttpGet("NewAddressType"), HypermediaActionParameterInfoEndpoint<NewAddress>]
public ActionResult NewAddressType()
{
    var schema = jsonSchemaFactory.Generate(typeof(NewAddress));
    return Ok(schema);
}

Also see: URL key extraction

File upload actions

Use FileUploadHypermediaAction or FileUploadHypermediaAction<TParameter> for file uploads (see Actions — File upload actions for the action definition). The endpoint uses DefaultMediaTypes.MultipartFormData:

Controller example:

[HttpPost("UploadImage"),
 HypermediaActionEndpoint<HypermediaCarsRootHto>(nameof(HypermediaCarsRootHto.UploadCarImage), DefaultMediaTypes.MultipartFormData)]
public async Task<IActionResult> UploadCarImage(
    [HypermediaUploadParameterFromForm]
    HypermediaFileUploadActionParameter<UploadCarImageParameters> uploadParameters)
{
    var files = uploadParameters.Files; // Access uploaded files
    var additionalParameter = uploadParameters.ParameterObject; // Access additional typed parameter
    //...
}

Files are uploaded using multipart/form-data. The additional parameter is added as a serialized json string to the key-value-dictionary of the form.

C# client example:

// hco definition
[HypermediaClientObject("CarsRoot")]
public partial class HypermediaCarsRootHco : HypermediaClientObject
{
    [HypermediaCommand("UploadCarImage")]
    public IHypermediaClientFileUploadFunction<CarImageHco, UploadCarImageParameters>? UploadCarImage { get; set; }
}

// usage
HypermediaCarsRootHco hco;
hco.UploadCarImage.ExecuteAsync(
    new HypermediaFileUploadActionParameter<UploadCarImageParameters>(
        FileDefinitions: [
            new FileDefinition(async () => new MemoryStream(new byte[] { 1, 2, 3, 4 }), "Bytes", "Bytes.txt"),
        ],
        new UploadCarImageParameters(...)),
    Resolver);

Note that the name and filename are mandatory in order for the file to be recognized as a file and not as a “normal” parameter.

Routes with a placeholder in the route template

For access to entities a route template may contain placeholder variables like key in the example below. If an HTO is referenced, e.g. the self link or a link to another Customer, the formatter must be able to create the URI to the linked HTO. To properly fill the placeholder variables for such routes, the framework needs to know how to extract key values from the HTO.

Use attributes to indicate keys

Use the Key attribute to indicate which properties of the HTO should be used to fill the route template variables. If there is only one variable to fill it is enough to put the attribute above the desired HTO property.

A HypermediaObjectEndpoint<T> route must exist for the resolution to be added.

Example:

The route template: [HttpGet("{key:int}"), HypermediaObjectEndpoint<MyHto>]

The attributed HTO:

public class MyHto : IHypermediaObject
{
    [Key]
    public int Id { get; set; }
    ...

If the route has more than one variable, the Key attribute receives the name of the related route template variable.

Example:

The route template: [HttpGet("{brand}/{key:int}"), HypermediaObjectEndpoint<HypermediaCarHto>]

The attributed HTO:

[HypermediaObject(Title = "A Car", Classes = new[] { "Car" })]
public class HypermediaCarHto : IHypermediaObject
{
    // Marks property as part of the objects key so it can be mapped to route parameters when creating links.
    [Key("brand")]
    public string Brand { get; set; }

    // Marks property as part of the objects key so it can be mapped to route parameters when creating links
    [Key("key")]
    public int Id { get; set; }
    ...
}

Use a Key record

Alternatively, define a nested Key record that derives from HypermediaObjectKeyBase<T>:

public class HypermediaCarHto : IHypermediaObject
{
    public int? Id { get; set; }
    public string? Brand { get; set; }

    public record Key(int? Id, string? Brand) : HypermediaObjectKeyBase<HypermediaCarHto>
    {
        protected override IEnumerable<KeyValuePair<string, object?>> EnumerateKeysForLinkGeneration()
        {
            yield return new KeyValuePair<string, object?>("id", this.Id);
            yield return new KeyValuePair<string, object?>("brand", this.Brand);
        }
    }
}

Use a custom KeyProducer

To use a custom KeyProducer, implement IKeyProducer and add it to the attributed route:

[HttpGet("{key:int}"), HypermediaObjectEndpoint<HypermediaCustomerHto>(typeof(CustomerRouteKeyProducer))]
public async Task<ActionResult> GetEntity(int key)
{
    ...
}

The formatter will call the producer if it has an instance of the referenced object (e.g. from Link.To()) and passes it to IKeyProducer.CreateFromHypermediaObject(). Otherwise it will call IKeyProducer.CreateFromKeyObject() and passes the key provided by Link.ByKey(). The KeyProducer must return an anonymous object filled with a property for each placeholder variable to be filled in the HTO’s route.

See CustomerRouteKeyProducer in the demo project for an example.

By design the extension encourages routes to not have multiple keys in the route template. Also only routes to an HTO may have a key. Actions related to an HTO must be available as a sub route to its corresponding object so required route template variables can be filled for the action’s host HTO.

Example:

http://localhost:5000/Customers/{key}
http://localhost:5000/Customers/{key}/Move

Queries

Clients shall not build query strings. Instead they post a JSON object to an action and receive the URI to the desired query result in the Location header.

[HttpPost("Queries"),
 HypermediaActionEndpoint<HypermediaCustomersRootHto>(nameof(HypermediaCustomersRootHto.CreateQuery))]
public ActionResult NewQueryAction(CustomerQuery query)
{
    if (query == null)
    {
        return this.Problem(ProblemJsonBuilder.CreateBadParameters());
    }

    if (!customersRoot.CreateQuery.CanExecute())
    {
        return this.CanNotExecute();
    }

    // Will create a Location header with a URI to the result.
    return this.Created(Link.ByQuery<HypermediaCustomerQueryResultHto>(query));
}

There must be a companion route which receives the query object and returns the query result:

[HttpGet("Query"), HypermediaObjectEndpoint<HypermediaCustomerQueryResultHto>]
public async Task<ActionResult> Query([FromQuery] CustomerQuery query)
{
    var queryResult = await customerRepository.QueryAsync(query);
    var resultReferences = new List<HypermediaCustomerHto>();
    foreach (var customer in queryResult.Entities)
    {
        resultReferences.Add(customer.ToHto());
    }

    var queries = NavigationQuerysBuilder.Create(query, queryResult);
    var result = new HypermediaCustomerQueryResultHto(
        queryResult.TotalCountOfEnties,
        resultReferences.Count,
        resultReferences,
        queries.next.Map(IHypermediaQuery (some) => some),
        queries.previous.Map(IHypermediaQuery (some) => some),
        queries.last.Map(IHypermediaQuery (some) => some),
        queries.all.Map(IHypermediaQuery (some) => some),
        query);

    return Ok(result);
}