Tutorials / Build a REST API with C# and .NET · Lesson 33 of 34
Frontend
On this page
The Books Screen
The page from the last lesson can only look. The owner of the shop also needs to add a new book when a delivery arrives, correct a price that was typed wrongly, and remove a title that is no longer sold. The API can do all three. The page has to learn to ask for them.
Our goal: the same page adds, edits and deletes books, and shows the API's refusals in plain words.
Here is the route:
- Add a form to the page, and see how its boxes become the JSON the API wants.
- Add a book with a
POST. - Show the API's refusals, using the shape from the errors lesson.
- Edit a book with a
PUT, using the same form. - Delete a book with a
DELETE.
This lesson leaves out how the page looks (it is ugly on purpose, as before), the "sell a copy" endpoint, the report page, and logging in. Checks inside the browser are also left out on purpose, and the lesson says why. It adds no framework and no tests: the checking is done by using the page.
The page is now long enough that each step shows only the new pieces, and says where they go. The whole file is shown once, at the end.
Where we are
This is the solution from the end of the first web page. Only one file changes:
BookShop/
src/
BookShop.Api/
wwwroot/
index.html <- changes (a form, buttons and more script)
Only the files that change are listed. Run every command from the solution folder, BookShop/. No project is added, so there is no solution map. Start the app with dotnet run --project src/BookShop.Api and keep it running. The page is only a file, so you refresh the browser after each edit and do not restart.
From boxes to a request
Adding a book means sending five values: a title, an author, a price, a stock and a category. A form is the HTML way to collect them. The picture below follows one book from the boxes on the screen to the API:
Open src/BookShop.Api/wwwroot/index.html. Put this form in the body, between the <ul> and the <table>. (The <ul id="problems"> is a list for the API's refusals, which we use in a moment. Add it just under the <p id="message"> line.)
<p id="message"></p>
<ul id="problems"></ul> <!-- new -->
<form id="book-form" novalidate> <!-- new, down to </form> -->
<input type="hidden" name="bookId">
<p><label>Title <input name="title"></label></p>
<p><label>Author <input name="author"></label></p>
<p><label>Price <input name="price" type="number" step="0.01"></label></p>
<p><label>Stock <input name="stock" type="number"></label></p>
<p><label>Category <input name="category"></label></p>
<p>
<button type="submit">Save</button>
<button type="button" id="cancel-edit" hidden>Cancel</button>
</p>
</form>
<form>groups the boxes.<input>is a box, and itsnameis how the script finds it.<label>is the caption, and clicking it puts the cursor in the box.type="number"makes the box accept only numbers.step="0.01"says the price moves in cents.novalidateswitches off the browser's own checking. A browser can stop a form from being sent when a box is wrong, with a little pop-up. That is a kindness to the visitor, but it is not a safety net: anyone can send a request without our page. The API checks everything again, as more validation rules set out. For this lesson we want to see the API's answers, so the browser must not get in the way. A real page often keeps both.type="hidden"is a box nobody sees. It will hold the id of the book being edited. We use it in the edit step.- The Cancel button has the
hiddenattribute, so it does not show until we edit.
Refresh. You see five boxes and a Save button above the table.
Add a book
Now the script. Inside the <script>, above the call loadBooks(); at the bottom, add these pieces. First, two names the other pieces use, and a function that reads the boxes into one object:
const form = document.getElementById('book-form'); // new
const cancelButton = document.getElementById('cancel-edit'); // new
function readBook() { // new
return { // new
title: form.elements.title.value, // new
author: form.elements.author.value, // new
price: Number(form.elements.price.value), // new
stock: Number(form.elements.stock.value), // new
category: form.elements.category.value // new
}; // new
} // new
form.elements.title is the box with name="title", and .value is what is typed in it. The values of a box are always text, even in a number box. Number(...) turns the text into a real number. Why bother? Try the tempting shortcut and send the price as it is, with an empty box. The text in the JSON is then "":
{"title":"T","author":"A","price":"","stock":5,"category":"C"}
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"request": ["The request field is required."],
"$.price": ["The JSON value could not be converted to System.Decimal. Path: $.price | LineNumber: 0 | BytePositionInLine: 36."]
}
}
(Shown one field per line, here and below. The real body is a single line, and ends with a traceId we ignore.) That is the second kind of 400 from more validation rules, the one that does not come from our rules, and its message is a .NET error that means nothing to the owner. With Number(''), an empty box becomes 0, which reaches the API as a normal number and meets our own price rule, with a message the owner can read.
Next, the function that reads the API's refusals. It is the main part of this lesson, so here it is on its own:
function clearProblems() { // new
document.getElementById('message').textContent = ''; // new
document.getElementById('problems').replaceChildren(); // new
} // new
async function showProblem(response) { // new
const problem = await response.json(); // new
document.getElementById('message').textContent = problem.title; // new
const list = document.getElementById('problems'); // new
list.replaceChildren(); // new
for (const messages of Object.values(problem.errors ?? {})) { // new
for (const text of messages) { // new
const item = document.createElement('li'); // new
item.textContent = text; // new
list.append(item); // new
} // new
} // new
} // new
Every refusal from our API has the Problem Details shape, so one function can read them all. A refusal always has a title, which goes on the message line. A 400 from validation also has an errors object: a field name, then a list of messages. problem.errors ?? {} means "the errors, or nothing if there are none" (a 404 has none). Object.values(...) takes the lists and drops the field names, then two small loops add one list item per message. The messages already say which field they are about ("The Title field is required."), so the page does not need the names. (The names are the C# ones, Title, while the book JSON uses title, as creating a book pointed out. Marking the wrong box in red would need a mapping between the two. We skip it.) createElement('li') makes a new list item, and textContent fills it, as before.
Last, the handler that sends the form:
form.addEventListener('submit', async (event) => { // new
event.preventDefault(); // new
clearProblems(); // new
const response = await fetch('/books', { // new
method: 'POST', // new
headers: { 'Content-Type': 'application/json' }, // new
body: JSON.stringify(readBook()) // new
}); // new
if (response.ok) { // new
form.reset(); // new
await loadBooks(); // new
} else { // new
await showProblem(response); // new
} // new
}); // new
addEventListener('submit', ...)says "when the form is submitted, run this function". Pressing the Save button, or Enter in a box, submits the form.event.preventDefault()cancels what the browser would do by itself. By default a submitted form leaves the page, sends its boxes in an older format and loads a new page. We want to stay on the page and send JSON ourselves.- The second argument of
fetchdescribes the request.methodis the verb.headerscarriesContent-Type: application/json, without which the API answers415, as in creating a book.bodyis the JSON text, andJSON.stringifyturns our object into it. - On success the form is cleared (
form.reset()) and the list is loaded again. We could add the new row ourselves, since the201carries the new book. We reload instead: it is one line, the list stays in the order the API gives, and the page shows what the server really holds. - On failure the refusal goes to
showProblem.
Refresh the page and try each case, one at a time.
Case A: a good book. Type Domain-Driven Design, Eric Evans, 54.9, 5 and Programming, then press Save. The page sends this (the Network tab shows each request, and clicking one shows what was sent and what came back):
POST /books
{"title":"Domain-Driven Design","author":"Eric Evans","price":54.9,"stock":5,"category":"Programming"}
201 Created
{"id":6,"title":"Domain-Driven Design","author":"Eric Evans","price":54.9,"stock":5,"category":"Programming"}
The boxes empty, and the table now has six rows, the new one in its place by title:
Clean Code | Robert C. Martin | 34.50 | 7 | Programming
Domain-Driven Design | Eric Evans | 54.90 | 5 | Programming
Refactoring | Martin Fowler | 44.00 | 3 | Programming
...
(Your id may differ. The last three rows are as before.)
Case B: an empty form. Press Save with nothing typed. The page sends:
POST /books
{"title":"","author":"","price":0,"stock":0,"category":""}
400 Bad Request
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Price": ["The field Price must be between 0.01 and 10000."],
"Title": ["The Title field is required."],
"Author": ["The Author field is required."],
"Category": ["The Category field is required."]
}
}
The page shows the title of the refusal, then one line for each message:
One or more validation errors occurred.
- The field Price must be between 0.01 and 10000.
- The Title field is required.
- The Author field is required.
- The Category field is required.
Nothing was saved, and nothing in the table changed. Four rules written in more validation rules now reach the screen, and the page needed no rule of its own.
Case C: a price with too many decimals. Type X, Y, 9.999, 0 and Z. The page sends "price":9.999, and the API answers 400 with one message, which the page shows:
One or more validation errors occurred.
- The field Price must have at most 2 decimal places.
That is the rule of our own from more validation rules, with no page code at all. Clear the boxes by refreshing before you go on.
Edit a book
Editing needs no second form. The same boxes serve both jobs, and one hidden box tells them apart: if it holds a book's id, Save means "replace that book"; if it is empty, Save means "add a new one".
Two new functions fill the form from a book (Edit) and empty it again (Cancel, or after saving):
function startEdit(book) { // new
clearProblems(); // new
form.elements.bookId.value = book.id; // new
form.elements.title.value = book.title; // new
form.elements.author.value = book.author; // new
form.elements.price.value = book.price; // new
form.elements.stock.value = book.stock; // new
form.elements.category.value = book.category; // new
cancelButton.hidden = false; // new
} // new
function stopEdit() { // new
form.reset(); // new
form.elements.bookId.value = ''; // new
cancelButton.hidden = true; // new
} // new
cancelButton.addEventListener('click', stopEdit); // new
The submit handler now picks the verb and the address by looking at the hidden box. It replaces the handler of the last step:
form.addEventListener('submit', async (event) => {
event.preventDefault();
clearProblems();
const id = form.elements.bookId.value; // new
const response = await fetch(id ? '/books/' + id : '/books', { // new
method: id ? 'PUT' : 'POST', // new
// method: 'POST', // replaced
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(readBook())
});
if (response.ok) {
stopEdit(); // new
// form.reset(); // replaced
await loadBooks();
} else {
await showProblem(response);
}
});
The commented lines are temporary, as in earlier lessons: delete them once you have seen where the code went. id ? a : b means "if id has text in it, a, otherwise b", and an empty text counts as "no". Note what a PUT sends: all five values, with nothing left out, because update and delete made a PUT replace the whole book. readBook() already gives all five.
The last piece is a button on each row to start the edit. It goes in loadBooks. The whole function, with a new cell at the end of every row, is:
async function loadBooks() {
const response = await fetch('/books');
if (!response.ok) {
await showProblem(response); // new
// document.getElementById('message').textContent = ... // replaced
return;
}
const books = await response.json();
const rows = document.getElementById('book-rows');
rows.replaceChildren();
for (const book of books) {
const row = rows.insertRow();
row.insertCell().textContent = book.title;
row.insertCell().textContent = book.author;
row.insertCell().textContent = book.price.toFixed(2);
row.insertCell().textContent = book.stock;
row.insertCell().textContent = book.category;
const editButton = document.createElement('button'); // new
editButton.textContent = 'Edit'; // new
editButton.addEventListener('click', () => startEdit(book)); // new
row.insertCell().append(editButton); // new
}
}
() => startEdit(book) is a small function that remembers which book its row came from, so each button edits its own row. showProblem now handles a failed load too, so the sentence "Could not load the books..." is gone. The API's own title ("Not Found" and so on) is shown instead. Also add a blank <th></th> at the end of the header row, so the new column has a header cell.
Refresh, and try two cases.
Case D: change a stock. Press Edit on Clean Code. The form fills with its values, and a Cancel button appears. Change the stock from 7 to 9 and press Save. The page sends:
PUT /books/2
{"title":"Clean Code","author":"Robert C. Martin","price":34.5,"stock":9,"category":"Programming"}
204 No Content
A 204 has no body, so there is nothing to read; the page just reloads the list, and the row now ends in 9:
Clean Code | Robert C. Martin | 34.50 | 9 | Programming
Case E: the book is gone. Press Edit on Refactoring. Before pressing Save, delete the book from outside the page, as another user might:
curl -i -X DELETE http://localhost:5101/books/3
HTTP/1.1 204 No Content
Now press Save. The page sends PUT /books/3 with the form's values, and the API answers:
404 Not Found
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Not Found","status":404,"traceId":"00-3526d7d12fcefd703b6f3e2f049f4eff-77913d544eced522-00"}
The showProblem function that we wrote for the 400 handles it with no change. It prints the title and finds no errors, so the page shows one line, Not Found. The list on the screen is stale (it still shows Refactoring) until the page is refreshed. Real pages reload the list on a 404; we leave it out to keep this one short.
Put the book back before you go on, if you want your five books: reload the page and, if it is missing, add it with the form.
Delete a book
A delete needs no form and no body, only the id in the address. Add a second button in the same cell, and the function it calls:
const deleteButton = document.createElement('button'); // new
deleteButton.textContent = 'Delete'; // new
deleteButton.addEventListener('click', () => deleteBook(book)); // new
row.insertCell().append(editButton, ' ', deleteButton); // new
// row.insertCell().append(editButton); // replaced
async function deleteBook(book) { // new
if (!confirm('Delete "' + book.title + '"?')) { // new
return; // new
} // new
clearProblems(); // new
const response = await fetch('/books/' + book.id, { method: 'DELETE' }); // new
if (response.ok) { // new
await loadBooks(); // new
} else { // new
await showProblem(response); // new
} // new
} // new
confirm(...)is the browser's built-in "OK or Cancel" question. A delete cannot be undone, so asking first is worth one line. It returnsfalsewhen the visitor cancels, and then we do nothing.fetch(..., { method: 'DELETE' })needs no headers and no body: the address says which book.
Case F: delete a book. Press Delete on Domain-Driven Design and confirm. The page sends:
DELETE /books/6
204 No Content
The list reloads without that row. Pressing Cancel in the question sends nothing at all.
The whole page
The pieces are in; here is the file you should have, so that you can compare. The whole file is:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Books</title>
</head>
<body>
<h1>Books</h1>
<p id="message"></p>
<ul id="problems"></ul>
<form id="book-form" novalidate>
<input type="hidden" name="bookId">
<p><label>Title <input name="title"></label></p>
<p><label>Author <input name="author"></label></p>
<p><label>Price <input name="price" type="number" step="0.01"></label></p>
<p><label>Stock <input name="stock" type="number"></label></p>
<p><label>Category <input name="category"></label></p>
<p>
<button type="submit">Save</button>
<button type="button" id="cancel-edit" hidden>Cancel</button>
</p>
</form>
<table>
<thead>
<tr>
<th>Title</th>
<th>Author</th>
<th>Price</th>
<th>Stock</th>
<th>Category</th>
<th></th>
</tr>
</thead>
<tbody id="book-rows"></tbody>
</table>
<script>
const form = document.getElementById('book-form');
const cancelButton = document.getElementById('cancel-edit');
async function loadBooks() {
const response = await fetch('/books');
if (!response.ok) {
await showProblem(response);
return;
}
const books = await response.json();
const rows = document.getElementById('book-rows');
rows.replaceChildren();
for (const book of books) {
const row = rows.insertRow();
row.insertCell().textContent = book.title;
row.insertCell().textContent = book.author;
row.insertCell().textContent = book.price.toFixed(2);
row.insertCell().textContent = book.stock;
row.insertCell().textContent = book.category;
const editButton = document.createElement('button');
editButton.textContent = 'Edit';
editButton.addEventListener('click', () => startEdit(book));
const deleteButton = document.createElement('button');
deleteButton.textContent = 'Delete';
deleteButton.addEventListener('click', () => deleteBook(book));
row.insertCell().append(editButton, ' ', deleteButton);
}
}
function readBook() {
return {
title: form.elements.title.value,
author: form.elements.author.value,
price: Number(form.elements.price.value),
stock: Number(form.elements.stock.value),
category: form.elements.category.value
};
}
function startEdit(book) {
clearProblems();
form.elements.bookId.value = book.id;
form.elements.title.value = book.title;
form.elements.author.value = book.author;
form.elements.price.value = book.price;
form.elements.stock.value = book.stock;
form.elements.category.value = book.category;
cancelButton.hidden = false;
}
function stopEdit() {
form.reset();
form.elements.bookId.value = '';
cancelButton.hidden = true;
}
async function deleteBook(book) {
if (!confirm('Delete "' + book.title + '"?')) {
return;
}
clearProblems();
const response = await fetch('/books/' + book.id, { method: 'DELETE' });
if (response.ok) {
await loadBooks();
} else {
await showProblem(response);
}
}
function clearProblems() {
document.getElementById('message').textContent = '';
document.getElementById('problems').replaceChildren();
}
async function showProblem(response) {
const problem = await response.json();
document.getElementById('message').textContent = problem.title;
const list = document.getElementById('problems');
list.replaceChildren();
for (const messages of Object.values(problem.errors ?? {})) {
for (const text of messages) {
const item = document.createElement('li');
item.textContent = text;
list.append(item);
}
}
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
clearProblems();
const id = form.elements.bookId.value;
const response = await fetch(id ? '/books/' + id : '/books', {
method: id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(readBook())
});
if (response.ok) {
stopEdit();
await loadBooks();
} else {
await showProblem(response);
}
});
cancelButton.addEventListener('click', stopEdit);
loadBooks();
</script>
</body>
</html>
The page is ugly, and it works. It contains no business rule. It does not know that a price must be between 0.01 and 10000, or that a title may have 200 letters. Those live in the API, and the page only shows what the API says. If the shop changes a rule tomorrow, the page needs no change.
Where we ended
Compare with your solution. Only the page changed:
BookShop/
src/
BookShop.Api/
wwwroot/
index.html the Books screen: form, table, Edit and Delete buttons
Recap
- A form collects the values. The script reads the boxes, makes them into one object, and sends it with
JSON.stringify. event.preventDefault()keeps the browser from leaving the page when a form is submitted.- A request from a script needs three parts: the verb (
method), the headerContent-Type: application/json, and the body. - Values in boxes are text.
Number(...)makes the numbers real, so an empty box becomes a normal0that meets the API's own rule. - One function reads every refusal. Problem Details always has a
title, and a validation400addserrors, a list of messages per field. - One form serves both add and edit. A hidden box holds the book's id: empty means
POST /books, filled meansPUT /books/{id}. APUTsends all five values. - A delete needs no body, and a built-in
confirmasks first because it cannot be undone. - The page holds no rules. It shows what the API says, so a rule changes in one place.
Questions to think about
Each question has its answer folded away underneath. Guess first, then open it to check.
- Why does the script call
event.preventDefault()when the form is submitted?
Show answer
Without it the browser would leave the page and send the form its own way. We want to stay on the page and send JSON ourselves.
- How can one form serve both adding and editing?
Show answer
A hidden box holds the id of the book being edited. If it is empty, Save sends a POST to add a new book. If it has an id, Save sends a PUT to replace that book.
- Why can one function show the
400from validation and the404for a missing book?
Show answer
Every refusal from the API has the same Problem Details shape, with a title. A validation 400 also has a list of messages, and the function simply shows them when they are there.
- Why does the page not contain the rule "a price must be at least 0.01"?
Show answer
Only the API's check cannot be skipped, and a rule written in two places can drift apart. The page shows what the API says, so a rule lives and changes in one place.
- The report of stock per category is still only a
curlcommand. What would the report page need? (The next lesson answers this.)
Show answer
A second page that asks the report endpoint and shows each category in a table, the way the first page shows the books.
Next lesson: the report screen, a second page that shows the stock totals per category.