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:

    1. Add a form to the page, and see how its boxes become the JSON the API wants.
    2. Add a book with a POST.
    3. Show the API's refusals, using the shape from the errors lesson.
    4. Edit a book with a PUT, using the same form.
    5. 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:

    From the form to the request The form Title [ ] Author [ ] Price [ ] Stock [ ] Category [ ] The script reads the boxes makes the values into JSON text The request POST /books the JSON is the body

    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 its name is 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.
    • novalidate switches 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 hidden attribute, 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 fetch describes the request. method is the verb. headers carries Content-Type: application/json, without which the API answers 415, as in creating a book. body is the JSON text, and JSON.stringify turns 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 the 201 carries 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".

    One form, two jobs Save is pressed hidden id box? empty: POST /books a new book is added holds 2: PUT /books/2 book 2 is replaced

    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 returns false when 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 header Content-Type: application/json, and the body.
    • Values in boxes are text. Number(...) makes the numbers real, so an empty box becomes a normal 0 that meets the API's own rule.
    • One function reads every refusal. Problem Details always has a title, and a validation 400 adds errors, 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 means PUT /books/{id}. A PUT sends all five values.
    • A delete needs no body, and a built-in confirm asks 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.

    1. 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.

    1. 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.

    1. Why can one function show the 400 from validation and the 404 for 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.

    1. 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.

    1. The report of stock per category is still only a curl command. 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.

    ← The First Web PageThe Report Screen →