DEV Community

Cover image for Create a CRUD app with Go Fiber, docker, and Postgres
Div Rhino
Div Rhino

Posted on • Originally published at divrhino.com

Create a CRUD app with Go Fiber, docker, and Postgres

Introduction

CRUD refers to the four basic operations an app should be able to perform. The acronym represents the Create, Read, Update, and Delete functions.

In the first and second parts of this tutorial series, we learnt how to Create and Read from a database. In this third instalment, we will learn how to Update and Delete data from a database.

Prerequisites

To follow along, you will need to have Docker installed and running.

You will also need the source code from the second part of the tutorial series. If you did not happen to follow that tutorial, you can download the finished code and use it as your starting point.

Remember to also set up your environment variables to avoid errors.

Revisiting fact creation

Before we start adding new functionality, let’s first improve some existing code.

In the previous tutorial, we had set up a confirmation page which we redirected to after we successfully created a new Fact. We did this to get some practice creating new handlers, now we can update this behaviour so that we are redirecting to the index page, instead.

In the handlers/facts.go file, let’s update the CreateFact handler. If we get errors when trying to parse the body of the request, we will render the new template. And if we get an error when trying to save the Fact to the database, we will also just render the new template. But if can successfully save the new Fact, then we will redirect to the index page to see our newly created Fact being listed:

func CreateFact(c *fiber.Ctx) error {
    fact := new(models.Fact)
    // Parse request body
    if err := c.BodyParser(fact); err != nil {
        return NewFactView(c)
    }

    // Create fact in database
    result := database.DB.Db.Create(&fact)
    if result.Error != nil {
        return NewFactView(c)
    }

    return ListFacts(c)
}
Enter fullscreen mode Exit fullscreen mode

Now that our CreateFact handler is no longer using the ConfirmationView handler, we can delete it.

Read

In the first part of this tutorial series, we already learnt how to read all our Facts from the database. Then in the second part, we built a UI to display them all. To get more practice with reading from the database, we will now learn how to Show a single fact on a page of its own.

In our browser window, we can navigate to localhost:3000/fact/1 because this will be the url format we will use to show a fact, given its id. Since we newly created a fact, we expect its id to be 1. However, this currently results in an error because we have nothing set up to support a show action.

Error cannot get fact/1

Route

Let’s head into the routes file to add a new show route so that our app recognises this new url format. Within the body of the setupRoutes() method, we can add a new route to show a single Fact. This route will use the GET action. It will also have a :id path parameter.

package main

import (
    "github.com/divrhino/divrhino-trivia/handlers"
    "github.com/gofiber/fiber/v2"
)

func setupRoutes(app *fiber.App) {
    app.Get("/", handlers.ListFacts)

    app.Get("/fact", handlers.NewFactView)
    app.Post("/fact", handlers.CreateFact)

    // Add new route to show single Fact, given `:id`
    app.Get("/fact/:id", handlers.ShowFact)
}
Enter fullscreen mode Exit fullscreen mode

We are referencing the ShowFact handler that does not exist yet. We can create that next.

Handler

Within the handlers package, we will create the ShowFact handler that will retrieve and render a single fact.

As with all handlers in Go Fiber, we pass in the context and return an error. Then within the body of the ShowFact handler, we create a new variable called fact to store a Fact model instance to hold the fact we will retrieve.

To be able to retrieve the Fact from the database, we will need to know its id. We can use the c.Params() method to pull this information from the URL.

A typical URL will look something like this:

Structure of a URL

We use Go Fiber’s c.Params() method when we’re specifically trying to get values from the path parameters. So c.Params("id") would get us the id value from the above example URL.

Once we have the id value, we can use it to query the database and get the First record where the id matches.

Then we can use the c.Render() method to render a frontend view.

func ShowFact(c *fiber.Ctx) error {
    fact := models.Fact{}
    id := c.Params("id")

    database.DB.Db.Where("id = ?", id).First(&fact)

    return c.Render("show", fiber.Map{
        "Title": "Single Fact",
        "Fact":  fact,
    })
}
Enter fullscreen mode Exit fullscreen mode

Let’s use the frontend views we already have to add a new Fact. If we try to view this newly-created Fact in our browser, we get the following error:

Error show template does not exist

We can fix this by creating a new template called show:

touch views/show.html
Enter fullscreen mode Exit fullscreen mode

Then within this view/show.html file, we can add some markup. The classes used in this markup correspond with styles that have been pre-prepared in the CSS. We will not go into depth about them, as CSS is out of scope for this tutorial.

<div class="container flex">
    {{if .Fact}}

    <div class="content">
        <p>{{ .Fact.Question }}</p>
        <p>{{ .Fact.Answer }}</p>
    </div>

    <div class="actions">
        <a href="" class="btn-secondary">Edit</a>
        <button class="btn-danger">Delete</button>
    </div>
    {{end}}
</div>
Enter fullscreen mode Exit fullscreen mode

We use an {{if}} block to check for the presence of a Fact and conditionally display content. If a Fact exists, we will render it along with an Edit link and a Delete button. If we head into the browser now, we should be able to see our single Fact being displayed by the show template.

Show template without CSS styling

However, you may notice that none of our CSS styles are being applied to this page. We can quickly fix this by heading into our view/layouts/main.html file and update the link to our stylesheet so that is has a preceding / in the path. So it should look like this:

<link rel="stylesheet" href="/style/main.css?v=1">
Enter fullscreen mode Exit fullscreen mode

And if your logo image is also missing, we can fix this by heading into the views/partials/header.html file and update the link to our image so that is has a preceding / in the path. So it will look like this:

<img src="/image/divrhino-logo.png" alt="">
Enter fullscreen mode Exit fullscreen mode

Now when we refresh the browser, we should see our styled show page

CSS styles fixed for show template

404 page

So far things are looking promising, but what happens if we try to request a fact that does not exist? We can find out by changing the id in our path parameter to 99.

Show a fact that does not exist

We’re getting an empty result but our show template is still rendering. This behaviour would be confusing to a user because they have no way of knowing what is going on. We can improve the experience by introducing a 404 page. If a fact does not exist, we should render this 404 page instead.

In the cmd/routes.go file, we update the ShowFact handler to return a NotFound when we are unable to retrieve the record. This NotFound handler does not exist yet, so we will need to create it.

func ShowFact(c *fiber.Ctx) error {
    fact := models.Fact{}
    id := c.Params("id")

    result := database.DB.Db.Where("id = ?", id).First(&fact)
    if result.Error != nil {
        return NotFound(c)
    }

    return c.Status(fiber.StatusOK).Render("show", fiber.Map{
        "Title": "Single Fact",
        "Fact":  fact,
    })
}

func NotFound(c *fiber.Ctx) error {
    return c.Status(fiber.StatusNotFound).SendFile("./public/404.html")
}
Enter fullscreen mode Exit fullscreen mode

Then we can go and update our app config in cmd/main.go to also use the NotFound handler. Remember to place this after the other setup, but right before the call to app.Listen

package main

import (
    "github.com/divrhino/divrhino-trivia/database"
    "github.com/divrhino/divrhino-trivia/handlers"
    "github.com/gofiber/fiber/v2"
    "github.com/gofiber/template/html"
)

func main() {
    database.ConnectDb()

    engine := html.New("./views", ".html")

    app := fiber.New(fiber.Config{
        Views:       engine,
        ViewsLayout: "layouts/main",
    })

    setupRoutes(app)

    app.Static("/", "./public")

    // Set up 404 page
    app.Use(handlers.NotFound)

    app.Listen(":3000")
}
Enter fullscreen mode Exit fullscreen mode

If we try to test this in the browser now, we will get a blank page. We need to create a 404.html file in our public folder:

touch public/404.html
Enter fullscreen mode Exit fullscreen mode

Then inside the public/404.html file, we can paste some ready-made content from the project repository.

All the markup and styles for this page will be inline. It is a good idea to keep error pages self-contained so they don’t throw additional errors if they are unable to location any external dependencies.

Now when we try to navigate to a route that does not exist, we see our new 404 page:

404 page

Our show page is done! Let’s keep going.

Update

The next CRUD action we will tackle is the Update operation. Again let’s start in our cmd/routes.go file.

If you find yourself not knowing where to start when implementing any action, try adding a new route and let the errors guide you.

In our cmd/routes.go file, we need to add 2 new routes. We need a GET route that will take us to the edit form and another PATCH route that will actually update the record we are editing. GET and PATCH are HTTP verbs (methods) and aren’t specific to Go Fiber.

package main

import (
    "github.com/divrhino/divrhino-trivia/handlers"
    "github.com/gofiber/fiber/v2"
)

func setupRoutes(app *fiber.App) {
    app.Get("/", handlers.ListFacts)
    app.Get("/fact", handlers.NewFactView)
    app.Post("/fact", handlers.CreateFact)
    app.Get("/fact/:id", handlers.ShowFact)

    // Display `Edit` form
    app.Get("/fact/:id/edit", handlers.EditFact)
    // Update fact
    app.Patch("/fact/:id", handlers.UpdateFact)
}
Enter fullscreen mode Exit fullscreen mode

Neither of these handlers exist yet, so we can head into our handlers package to create them.

Within the body of the EditFact handler, we create a new variable called fact to store the Fact we will be editing. Then we use the c.Params("id") method call to get the id value from the URL.

We query the database and retrieve the first record where the id matches. If we can’t find it, we render the NotFound handler. Otherwise, we will Render the view template and send the fact variable through to the frontend.

You may notice that this handler is very similar to the ShowFact handler, the main difference being we’re rendering different frontend views for each. If you’re feeling adventurous, you could consider DRYing out the code that is common between the two handlers.

func EditFact(c *fiber.Ctx) error {
    fact := models.Fact{}
    id := c.Params("id")

    result := database.DB.Db.Where("id = ?", id).First(&fact)
    if result.Error != nil {
        return NotFound(c)
    }

    return c.Render("edit", fiber.Map{
        "Title":    "Edit Fact",
        "Subtitle": "Edit your interesting fact",
        "Fact":     fact,
    })
}
Enter fullscreen mode Exit fullscreen mode

The next handler we’ll need to create is the UpdateFact handler which will persist the changes to the database. If we run into an error during the parsing step, we will send back a Service Unavailable status. If we get an error when trying to write these changes to the database, we will redirect to the edit template

func UpdateFact(c *fiber.Ctx) error {
    fact := new(models.Fact)
    id := c.Params("id")

    // Parsing the request body
    if err := c.BodyParser(fact); err != nil {
        return c.Status(fiber.StatusServiceUnavailable).SendString(err.Error())
    }

    // Write updated values to the database
    result := database.DB.Db.Model(&fact).Where("id = ?", id).Updates(fact)
    if result.Error != nil {
        return EditFact(c)
    }

    return ShowFact(c)
}
Enter fullscreen mode Exit fullscreen mode

Now we need a frontend view so users can edit facts in the browser.

touch views/edit.html
Enter fullscreen mode Exit fullscreen mode

Inside the edit.html file, we will create a simple form with the id attribute of form-update-fact. We will also store the current fact’s id column value inside a data attribute called data-factid. These id values might be confusing, but just be mindful that the first id is an HTML attribute that can be targeted by JavaScript and the fact’s id refers to the database column.

Then within the form body, we will add an input field for question, an input field for answer, and a submit button.

<div class="container">
    <form id="form-update-fact" data-factid="{{ .Fact.ID }}">
        <label for="question">
            <span>Question</span>
            <input type="text" name="question">
        </label>

        <label for="answer">
            <span>Answer</span>
            <input type="text" name="answer">
        </label>

        <input type="submit" value="Update">
    </form>
</div>
Enter fullscreen mode Exit fullscreen mode

We will also head into the show.html page and update the edit link so that its href value is pointing to our new edit route. We use {{ .Fact.ID }} to populate the id of the current fact we want to edit.

<div class="container flex">
    {{if .Fact}}

    <div class="content">
        <p>{{ .Fact.Question }}</p>
        <p>{{ .Fact.Answer }}</p>
    </div>

    <div class="actions">
        <a href="/fact/{{ .Fact.ID }}/edit" class="btn-secondary">Edit</a>
        <button class="btn-danger">Delete</button>
    </div>
    {{end}}
</div>
Enter fullscreen mode Exit fullscreen mode

If we take a look at your Fact in the browser, it should be able to click on the Edit button. This will take you to the edit form

Edit form fields have no values

Currently, our form fields are empty. This isn’t ideal, because we should be able to see the values we want to change. We can remedy this by adding a value attribute to each of the fields. So the markup for our form should now look like this:

<div class="container">
    <form id="form-update-fact" data-factid="{{ .Fact.ID }}">
        <label for="question">
            <span>Question</span>
            <!-- Add value for Question field -->
            <input type="text" name="question" value="{{ .Fact.Question }}">
        </label>

        <label for="answer">
            <span>Answer</span>
            <!-- Add value for Answer field -->
            <input type="text" name="answer" value="{{ .Fact.Answer }}">
        </label>

        <input type="submit" value="Update">
    </form>
</div>
Enter fullscreen mode Exit fullscreen mode

If we refresh our browser, the fields of our edit form should now have values

Edit form fields with values

But if we try to submit the form now, it does not work as expected. Why is this?

The HTML form element only supports the GET and POST methods, and the default method for a form is GET. However, our update route is using the PATCH method to update our facts. We will need a way to send a PATCH request from our edit form. We can achieve this with a little bit of JavaScript.

Send PATCH request with JavaScript

In the previous part of this tutorial series, we added some JavaScript to our views/index.html page because this was the only place in our app that was using JavaScript. Now, we’ve reached a point where we need JavaScript on other pages too, so we will need to import our script in a place that is accessible throughout the app. Let’s move our script tag from views/index.html into our main layout. So our views/layout/main.html file should now look like this:

<!DOCTYPE html>
<head>
    <title>Div Rhino Trivia</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link rel="stylesheet" href="/style/main.css">
</head>
<body>
    ...
</body>
</html>

<!-- import javascript file -->
<script src="/javascript/app.js"></script>
Enter fullscreen mode Exit fullscreen mode

By default, all our static assets are cached so that the app is faster. This makes sense when our app is in production, because we do not want to keep fetching styles and scripts that have not changed. However, this can be annoying in development, because we need to make frequent changes to our CSS and JS while we’re building things.

We can work around the caching by appending a timestamp to the end of the asset urls. This hack should only be used, locally, though. We do not want to have this behaviour in production.

First, we can add an id attribute to our <link> and <script> tags so we can target them with JavaScript. Then we can add a little snippet that will append a timestamp to the end of each path. Notice that we also removed the ?v=1 query param from the end of the stylesheet path.

<!DOCTYPE html>
<head>
    <title>Div Rhino Trivia</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">

    <!-- Add id to stylesheet link tag -->
    <link id="styles" rel="stylesheet" href="/style/main.css">
</head>
<body>
    ...
</body>
</html>

<!-- Add id to script tag -->
<script id="main" src="/javascript/app.js"></script>

<!-- For local development only: Snippet to append timestamps -->
<script>
    const timeStamp = Date.now()
    const stylesheetLink = document.querySelector("link#styles")
    const appScriptLink = document.querySelector("script#main")
    stylesheetLink.href += `?t=${timeStamp}`
    appScriptLink.src += `?t=${timeStamp}`
</script>
Enter fullscreen mode Exit fullscreen mode

We have now set up everything we need for the PATCH request. Inside the public/javascript/app.js file, we can paste in a snippet. This snippet uses the JavaScript fetch API to make a PATCH request to edit the current fact. JavaScript is outside the scope of this tutorial, so we will not step through every line of this snippet, but this is what is should look like:

// Get the editForm element and the current fact ID to edit
const editForm = document.querySelector('#form-update-fact')
const factToEdit = editForm && editForm.dataset.factid

// Add an event listener to listen for the form submit
editForm && editForm.addEventListener('submit', (event) => {
    // Prevent the default behaviour of the form element
    event.preventDefault()

    // Convert form data into a JavaScript object
    const formData = Object.fromEntries(new FormData(editForm));

    return fetch(`/fact/${factToEdit}`, {
        // Use the PATCH method
        method: 'PATCH',
        headers: {
            'Content-Type': 'application/json'
        },
        // Convert the form's Object data into JSON
        body: JSON.stringify(formData),
    })
    .then(() => document.location.href=`/fact/${factToEdit}`)// Redirect to show
})
Enter fullscreen mode Exit fullscreen mode

Now we can try this out in the browser. If we visit http://localhost:3000/fact/1/edit and change the values, we can submit the edit form to update the current Fact.

Delete

The last CRUD action we will implement is the delete action. First, we will head into our routes.go file to add a new delete route.

func setupRoutes(app *fiber.App) {
    app.Get("/", handlers.ListFacts)

    app.Get("/fact", handlers.NewFactView)
    app.Post("/fact", handlers.CreateFact)

    app.Get("/fact/:id", handlers.ShowFact)

    app.Get("/fact/:id/edit", handlers.EditFact)
    app.Patch("/fact/:id", handlers.UpdateFact)


    // Delete fact
    app.Delete("/fact/:id", handlers.DeleteFact)
}
Enter fullscreen mode Exit fullscreen mode

We are passing in a handler called DeleteFact, but it does not exist yet, so let’s head into our handlers package to create it. First we will find the current Fact using the provided id and then we will delete the fact using the Delete() method that GORM gives us. Once we have successfully deleted the fact, we will redirect to the index page

func DeleteFact(c *fiber.Ctx) error {
    fact := models.Fact{}
    id := c.Params("id")

    result := database.DB.Db.Where("id = ?", id).Delete(&fact)
    if result.Error != nil {
        return NotFound(c)
    }

    return ListFacts(c)
}
Enter fullscreen mode Exit fullscreen mode

Now that we can delete the app in the API, let’s wire up the delete button that appears on our show page. We will give it an id of delete-button so we can target it with JavaScript. Then we will also add a data-factid attribute so that we can pass the current fact’s id column value to JavaScript

<div class="container flex">
    {{if .Fact}}

    <div class="content">
        <p>{{ .Fact.Question }}</p>
        <p>{{ .Fact.Answer }}</p>
    </div>

    <div class="actions">
        <a href="/fact/{{ .Fact.ID }}/edit" class="btn-secondary">Edit</a>

        <!-- Add `id` and `data-factid` attributes to delete button -->
        <button id="delete-button" data-factid="{{ .Fact.ID }}" class="btn-danger">Delete</button>
    </div>
    {{end}}
</div>
Enter fullscreen mode Exit fullscreen mode

Then in our app.js file, we can add a little snippet that sends a DELETE request. We won’t go over this snippet in too much detail, but this is the basic idea:

  • When the deleteButton is clicked, we ask the user to confirm their decision to delete
  • Then we use the fetch API to send a DELETE request to our endpoint
  • After the fact is successfully deleted, we redirect to the index page
// Get deleteButton element and the current fact ID to delete
const deleteButton = document.querySelector('#delete-button')
const factToDelete = deleteButton && deleteButton.dataset.factid

// Add event listener to listen for button click
deleteButton && deleteButton.addEventListener('click', () => {
    // We ask the user if they are sure they want to delete the fact
    const result = confirm("Are you sure you want to delete this fact?")

    // If the user cancels the prompt, we exit here
    if (!result) return

    // If the user confirms that they want to delete, we send a DELETE request
    // URL uses the current fact's ID
    // Lastly, we redirect to index
    return fetch(`/fact/${factToDelete}`, { method: 'DELETE' })
            .then(() => document.location.href="/")
})
Enter fullscreen mode Exit fullscreen mode

If your JavaScript is not updating as expected, you may need to clear your browser’s cache or open the app in a private window.

Improving the index page

So far, we have been manually typing in the URL every time we want to see a single Fact. It would be a nicer experience if we could click on the facts from our List without having to remember the IDs for each one.

Back in browser, let’s add a few facts to work with:

Facts added to index page

Since we have a show page now, we no longer need the toggle answer button. So we can update our index.html page to remove it. The updated markup looks like this:

<div class="container">
    {{if .Facts}}
        {{ range .Facts }}
        <a href="/fact/{{ .ID }}">
            <div class="fact-item">
                <div class="question-wrapper">
                    <p class="question">{{ .Question }}</p>
                    <div class="actions">
                        {{template "icon-arrow-right" .}}
                    </div>
                </div>
            </div>
        </a>

        {{ end }}
    {{else}}
        <div class="no-facts">
            <p>No facts yet.</p>
            <p><a href="/fact">Add now</a></p>
        </div>
    {{end}}
</div>
Enter fullscreen mode Exit fullscreen mode

We are using a new partial called icon-arrow-right, but it does not exist yet. So let’s create that now.

First let’s create a new folder to hold all our icon partials

mkdir views/icons
Enter fullscreen mode Exit fullscreen mode

Then inside this new folder, let’s create our icon-arrow-right partial

touch views/icons/icon-arrow-right.html
Enter fullscreen mode Exit fullscreen mode

Then within icon-arrow-right.html, we can paste some SVG code:

{{define "icon-arrow-right"}}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="#4b5366" width="24" height="24">
    <path stroke-linecap="round" stroke-linejoin="round" d="M12.75 15l3-3m0 0l-3-3m3 3h-7.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{end}}
Enter fullscreen mode Exit fullscreen mode

If we refresh our app in the browser now, this is what it should look like

Update fact list ui

Now we can click on each fact and be taken to its individual show page where we can edit and delete it, easily.

And with that, we’ve come to the end.

Conclusion

In this tutorial we implemented all the CRUD actions - Create, Read, Update and Delete. We used JavaScript’s fetch API to make PATCH and DELETE requests. We also added a little hack to bust our browser’s cache during development.

Congratulations, you did great! Keep learning and keep coding. Bye for now, <3.

GitHub logo divrhino / divrhino-trivia-crud

Build a CRUD app with Go Fiber, docker and Postgres.






Top comments (15)

Collapse
 
maury_zr profile image
Mauricio Zabala

Hello, thanks for the tutorial, one question?, I don't know why air doesn't work for me when I edit and save a view, it doesn't reload the page in the browser, thanks.

Collapse
 
divrhino profile image
Div Rhino

Hi there! Are you able to see your changes when you refresh the browser? Air rebuilds your project, but it doesn't do hot reloading of the browser, so you still need to manually refresh it.

Collapse
 
maury_zr profile image
Mauricio Zabala

hello, thanks for answering, looking at the console, it only shows the actions with the database but does not rebuild the main.go when save the go file:

Image description

Thread Thread
 
divrhino profile image
Div Rhino

Hello! Would you be able to share the contents of your Dockerfile and docker-compose.yml files?

Thread Thread
 
maury_zr profile image
Mauricio Zabala

Image description

Image description

Thread Thread
 
divrhino profile image
Div Rhino

Hmmm, those files seem like they're all set up correctly. Is your air.toml pointing to your cmd directory?

Thread Thread
 
divrhino profile image
Div Rhino

Hello there. After a bit of googling, it appears that there are others also facing this issue when using Windows. Hoping this gets fixed soon.

Thread Thread
 
divrhino profile image
Div Rhino

Also found another thread in the repo issues that describes a potential fix:
github.com/cosmtrek/air/issues/190...

Collapse
 
maury_zr profile image
Mauricio Zabala

hello, another question, i cant connect to postgress database from navicat:

Image description

Collapse
 
divrhino profile image
Div Rhino

Hello there, sorry to hear that you're having trouble with this.

Here are a few things I would check:

First, the error seems to be indicating that the password is not correct? The tutorial is using divrhinotrivia. In your screenshot it looks like you are also using this value, but maybe it's worth checking again in case there was a typo.

Next, all these values for your connection should match up with the values you have in your .env file as well.

And lastly, your container should be running. You can get it going by running docker compose up.

Collapse
 
maury_zr profile image
Mauricio Zabala

Hello, thanks for answering, I have copied and pasted the values ​​of the env file in the navicat but it keeps giving me a connection error, I have tried restarting the server but it still does not connect externally, I tried with pgAdmin and the same error.

Image description

Thread Thread
 
divrhino profile image
Div Rhino

Hi there, are you able to use the UI to perform any CRUD actions?

Thread Thread
 
maury_zr profile image
Mauricio Zabala

Yes, the UI works correctly, the crud are ok

Thread Thread
 
maury_zr profile image
Mauricio Zabala

is only the remote conection to the database host

Thread Thread
 
maury_zr profile image
Mauricio Zabala

Thanks!