Building a basic REST AspNet Core Example - Part 3

In the previous part 1 & 2 we build an invoices controller with support for POST/PUT/GET/PATCH http methods. In this one we will add HTTP HEAD method and ETag support - I’ll repeat the disclamer - It is going to be pretty naive and basic, but I will add to it in future posts (hopefully). The source code for this post is at github.

Adding HTTP HEAD support

You can read about the HEAD verb in the HTTP protocol. But the part we are interested in is

The HEAD method is identical to GET except that the server MUST NOT send a message body in the response (i.e., the response terminates at the end of the header section). The server SHOULD send the same header fields in response to a HEAD request as it would have sent if the request had been a GET, except that the payload header fields MAY be omitted

The verb is useful for existence checking, validation that you have the latest version etc. Here we will use it for existance checking to start with. We will omit the Content-Length header since it is allowed to do so, and our use case does not rely on it.

So let’s add a minimum implementation to the InvoicesController

1
2
3
4
5
6
7
8
9
10
11
[HttpHead("{id}")]
public IActionResult HeadForInvoice(string id)
{
Response.ContentType = ApiDefinition.ApiMediaType;
if (!invoiceRepository.Exists(id))
{
return NotFound();
}
return Ok();
}

We’d still like to return the Content-Type so thus we set it on the response.

The solution is still only at richardson’s maturity model level 2. And we can do better in multiple areas.

ETag, If-match, If-none-match

Until now we have not really cared if our PUT call tries to update something even though it may have a stale view of the resource. The ETag header can help us here. We can specify the current entity version on GET response with an ETag header. When we do a PUT we can then specify the “If-match” header with the ETag. Thus we will only update the resource if the ETag matches. But to use the ETag we would want to use it on most Http verbs. The example below uses the invoice version as the ETag - in principle this should have been an “opaque” value instead.

GET

We should return an ETag in the response when a GET request is made. It could be done like this (somewhat clumpsy)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[HttpGet("{id}", Name = "GetInvoice")]
public IActionResult Get(string id)
{
var invoice = invoiceRepository.Get(id);
if (invoice == null)
{
return NotFound();
}
var responseHeaders = Response.GetTypedHeaders();
responseHeaders.ETag = new EntityTagHeaderValue($"\"{invoice.Version}\"");
return Ok(getInvoiceMapper.ToModel(invoice));
}

Please note the invoice has been extended with a Version property that is changed when the update method of the repository is called.

So now when we make a request the response headers can look like this

1
2
3
4
5
6
HTTP/1.1 200 OK
Date: Mon, 12 Dec 2016 16:21:21 GMT
Transfer-Encoding: chunked
Content-Type: application/vnd.restexample.finance+json; charset=utf-8
ETag: "e90baf01-40ce-4f46-89ea-227bb32fc421"
Server: Kestrel

We should also be able to handle the ETag if specified in the request. It will make sense to implement support for If-none-match header. We will return a new entity if the ETag does not match the current version of the invoice. If it matches we will return HTTP Not Modified.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
[HttpGet("{id}", Name = "GetInvoice")]
public IActionResult Get(string id)
{
var invoice = invoiceRepository.Get(id);
if (invoice == null)
{
return NotFound();
}
var currentETag = new EntityTagHeaderValue($"\"{invoice.Version}\"");
if (IfMatchGivenIfNoneMatch(currentETag))
{
return StatusCode((int)HttpStatusCode.NotModified);
}
var responseHeaders = Response.GetTypedHeaders();
responseHeaders.ETag = currentETag;
return Ok(getInvoiceMapper.ToModel(invoice));
}
private bool IfMatchGivenIfNoneMatch(EntityTagHeaderValue currentETag)
{
var requestHeaders = Request.GetTypedHeaders();
return requestHeaders.IfNoneMatch != null &&
requestHeaders.IfNoneMatch.Contains(currentETag);
}

So now we can add the If-None-Match header to our request like this

If-None-Match: “e90baf01-40ce-4f46-89ea-227bb32fc421”

and if it is current we get these response headers

1
2
3
HTTP/1.1 304 Not Modified
Date: Mon, 12 Dec 2016 17:28:02 GMT
Server: Kestrel

PUT/PATCH

Here we are going to return HTTP status Precondition Failed (415) if an ETag is provided and it does not match. The request should have the If-Match header added to it. Here is PUT

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
[HttpPut("{id}")]
public IActionResult Put(string id, [FromBody] UpdateInvoice updateInvoice)
{
if (updateInvoice == null)
{
return BadRequest();
}
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
if (!invoiceRepository.Exists(id))
{
return NotFound();
}
if (IfMatchIsInvalid(invoiceRepository.GetCurrentVersion(id)))
{
return StatusCode((int) HttpStatusCode.PreconditionFailed);
}
var newVersion = invoiceRepository.Update(updateInvoiceMapper.ToDomain(
updateInvoice, id)).Value;
var responseHeaders = Response.GetTypedHeaders();
responseHeaders.ETag = new EntityTagHeaderValue($"\"{newVersion}\"");
return NoContent();
}
private bool IfMatchIsInvalid(string currentVersion)
{
var requestHeaders = Request.GetTypedHeaders();
var currentETag = new EntityTagHeaderValue($"\"{currentVersion}\"");
return requestHeaders.IfMatch != null &&
!requestHeaders.IfMatch.Any(ifm => ifm.Equals(EntityTagHeaderValue.Any))
&& !requestHeaders.IfMatch.Contains(currentETag);
}

The ETag can be specified as * thus we also check against EntityTagHeaderValue.Any.

We do the same for PATCH

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
[HttpPatch("{id}")]
public IActionResult Patch(string id, [FromBody] JsonPatchDocument<UpdateInvoice> patchDocument)
{
if (patchDocument == null)
{
return BadRequest();
}
var invoice = invoiceRepository.Get(id);
if (invoice == null)
{
return NotFound();
}
if (IfMatchIsInvalid(invoice.Version))
{
return StatusCode((int) HttpStatusCode.PreconditionFailed);
}
var updateInvoice = updateInvoiceMapper.ToModel(invoice);
patchDocument.ApplyTo(updateInvoice, ModelState);
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var updatedDomainInvoice = updateInvoiceMapper.ToDomain(updateInvoice, id);
var newVersion = invoiceRepository.Update(updatedDomainInvoice);
var responseHeaders = Response.GetTypedHeaders();
responseHeaders.ETag = new EntityTagHeaderValue($"\"{newVersion}\"");
return NoContent();
}

HEAD should behave like GET so we can take that logic

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[HttpHead("{id}")]
public IActionResult HeadForInvoice(string id)
{
Response.ContentType = ApiDefinition.ApiMediaType;
Response.GetTypedHeaders();
if (!invoiceRepository.Exists(id))
{
return NotFound();
}
var currentVersion = invoiceRepository.GetCurrentVersion(id);
var currentETag = new EntityTagHeaderValue($"\"{currentVersion}\"");
if (IfMatchGivenIfNoneMatch(currentETag))
{
return StatusCode((int)HttpStatusCode.NotModified);
}
var responseHeaders = Response.GetTypedHeaders();
responseHeaders.ETag = currentETag;
return Ok();
}

POST

POST should return an ETag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[HttpPost]
public IActionResult Post([FromBody] UpdateInvoice updateInvoice)
{
if (updateInvoice == null)
{
return BadRequest();
}
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var createdInvoice = invoiceRepository.Create(updateInvoiceMapper.ToDomain(updateInvoice));
var responseHeaders = Response.GetTypedHeaders();
responseHeaders.ETag = new EntityTagHeaderValue($"\"{createdInvoice.Version}\"");
return CreatedAtRoute("GetInvoice",
new { id = createdInvoice.Id },
getInvoiceMapper.ToModel(createdInvoice));
}

Wrap-up

That is it for the most basic api for now. But we can do more as we will see in later posts (hopefully).