Hello, friends! ๐ Welcome to a really great tutorial. I've tried to make for you as simple step-by-step instructions as possible, based on a real-life application, so that you can apply this knowledge here and now.
I intentionally don't want to divide this tutorial into several disjointed parts, so that you don't lose the thought and focus. After all, I'm writing this tutorial only to share my experience and to show that backend development in Golang using the Fiber framework is easy!
At the end of the tutorial you will find a self-check block of knowledge, as well as a plan for further development of the application. So, I suggest you save the link to this tutorial to your bookmarks and share it on your social networks.
โค๏ธ Like, ๐ฆ Unicorn, ๐ Bookmark and let's go!
Let's create a REST API for an online library application in which we create new books, view them, and update & delete their information. But some methods will require us to authorize through providing a valid JWT access token. I'll store all the information about the books, as usual, in my beloved PostgreSQL.
I think, this functionality is enough to help you understand, how easy it is to work with Fiber web framework to create a REST API in Go.
Rename .env.example to .env and fill it with your environment values.
Install Docker and migrate tool for applying migrations.
Run project by this command:
make docker.run
# Process:# - Generate API docs by Swagger# - Create a new Docker network for containers# - Build and run Docker containers (Fiber, PostgreSQL)# - Apply database migrations (using github.com/golang-migrate/migrate)
If you want more articles like this on this blog, then post a comment below and subscribe to me. Thanks! ๐
And, of course, you can support me by donating at LiberaPay. Each donation will be used to write new articles and develop non-profit open-source projects forโฆ
./platform folder contains all the platform-level logic that will build up the actual project, like setting up the database or cache server instance and storing migrations.
./platform/database folder with database setup functions;
./platform/migrations folder with migration files;
I highly recommend using a Makefile for faster project management! But in this article, I want to show the whole process. So, I will write all commands directly, without magic make.
๐ If you already know it, here is a link to the full project's Makefile.
I know that some people like to use YML files to configure their Go applications, but I'm used to working with classical .env configurations and don't see much benefit from YML (even though I wrote an article about this kind of app configuration in Go in the past).
The config file for this project will be as follows:
Install and run Docker service for your OS. By the way, in this tutorial I'm using the latest version (at this moment) v20.10.2.
OK, let's make a new Docker network, called dev-network:
docker network create -d bridge dev-network
We will use it in the future when we run the database and the Fiber instance in isolated containers. If this is not done, the two containers will not be able to communicate with each other.
Check, if the container is running. For example, by ctop console utility:
Great! Now we are ready to do the migration of the original structure. Here is the file for up migration, called 000001_create_init_tables.up.sql:
-- ./platform/migrations/000001_create_init_tables.up.sql-- Add UUID extensionCREATEEXTENSIONIFNOTEXISTS"uuid-ossp";-- Set timezone-- For more information, please visit:-- https://en.wikipedia.org/wiki/List_of_tz_database_time_zonesSETTIMEZONE="Europe/Moscow";-- Create books tableCREATETABLEbooks(idUUIDDEFAULTuuid_generate_v4()PRIMARYKEY,created_atTIMESTAMPWITHTIMEZONEDEFAULTNOW(),updated_atTIMESTAMPNULL,titleVARCHAR(255)NOTNULL,authorVARCHAR(255)NOTNULL,book_statusINTNOTNULL,book_attrsJSONBNOTNULL);-- Add indexesCREATEINDEXactive_booksONbooks(title)WHEREbook_status=1;
โ๏ธ For easily working with an additional book attributes, I use JSONB type for a book_attrs field. For more information, please visit PostgreSQL docs.
And 000001_create_init_tables.down.sql for down this migration:
# ./DockerfileFROMgolang:1.16-alpineASbuilder# Move to working directory (/build).WORKDIR /build# Copy and download dependency using go mod.COPY go.mod go.sum ./RUN go mod download
# Copy the code into the container.COPY . .# Set necessary environment variables needed for our image # and build the API server.ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64RUN go build -ldflags="-s -w"-o apiserver .
FROM scratch# Copy binary and config files from /build # to root folder of scratch container.COPY --from=builder ["/build/apiserver", "/build/.env", "/"]# Export necessary port.EXPOSE 5000# Command to run when starting the container.ENTRYPOINT ["/apiserver"]
Yes, I'm using two-staged container build and Golang 1.16.x. App will be build with CGO_ENABLED=0 and -ldflags="-s -w" to reduce size of the finished binary. Otherwise, this is the most common Dockerfile for any Go project, that you can use anywhere.
Command to build the Fiber Docker image:
docker build -t fiber .
โ๏ธ Don't forget to add .dockerignore file to the project's root folder with all files and folders, which should be ignored when creating a container. Here is an example, what I'm using in this tutorial.
Command to create and start container from image:
docker run --rm-d\--name dev-fiber \--network dev-network \-p 5000:5000 \
fiber
As you can guess from the title, we're not going to worry too much about documenting our API methods. Simply because there is a great tool like Swagger that will do all the work for us!
swaggo/swag package for easily generate Swagger config in Go;
Well, we have prepared all the necessary configuration files and the working environment, and we know what we are going to create. Now it's time to open our favorite IDE and start writing code.
๐ Be aware, because I will be explaining some points directly in the comments in the code, not in the article.
Before implementing a model, I always create a migration file with an SQL structure (from the Chapter 3). This makes it much easier to present all the necessary model fields at once.
// ./app/models/book_model.gopackagemodelsimport("database/sql/driver""encoding/json""errors""time""github.com/google/uuid")// Book struct to describe book object.typeBookstruct{IDuuid.UUID`db:"id" json:"id" validate:"required,uuid"`CreatedAttime.Time`db:"created_at" json:"created_at"`UpdatedAttime.Time`db:"updated_at" json:"updated_at"`UserIDuuid.UUID`db:"user_id" json:"user_id" validate:"required,uuid"`Titlestring`db:"title" json:"title" validate:"required,lte=255"`Authorstring`db:"author" json:"author" validate:"required,lte=255"`BookStatusint`db:"book_status" json:"book_status" validate:"required,len=1"`BookAttrsBookAttrs`db:"book_attrs" json:"book_attrs" validate:"required,dive"`}// BookAttrs struct to describe book attributes.typeBookAttrsstruct{Picturestring`json:"picture"`Descriptionstring`json:"description"`Ratingint`json:"rating" validate:"min=1,max=10"`}// ...
๐ I recommend to use the google/uuid package to create unique IDs, because this is a more versatile way to protect your application against common number brute force attacks. Especially if your REST API will have public methods without authorization and request limit.
But that's not all. You need to write two special methods:
Value(), for return a JSON-encoded representation of the struct;
Scan(), for decode a JSON-encoded value into the struct fields;
They might look like this:
// ...// Value make the BookAttrs struct implement the driver.Valuer interface.// This method simply returns the JSON-encoded representation of the struct.func(bBookAttrs)Value()(driver.Value,error){returnjson.Marshal(b)}// Scan make the BookAttrs struct implement the sql.Scanner interface.// This method simply decodes a JSON-encoded value into the struct fields.func(b*BookAttrs)Scan(valueinterface{})error{j,ok:=value.([]byte)if!ok{returnerrors.New("type assertion to []byte failed")}returnjson.Unmarshal(j,&b)}
These fields are the biggest concern, because in some scenarios they will come to us from users. By the way, that's why we not only validate them, but consider them required.
And this is how I implement the validator:
// ./app/utils/validator.gopackageutilsimport("github.com/go-playground/validator/v10""github.com/google/uuid")// NewValidator func for create a new validator for model fields.funcNewValidator()*validator.Validate{// Create a new validator for a Book model.validate:=validator.New()// Custom validation for uuid.UUID fields._=validate.RegisterValidation("uuid",func(flvalidator.FieldLevel)bool{field:=fl.Field().String()if_,err:=uuid.Parse(field);err!=nil{returntrue}returnfalse})returnvalidate}// ValidatorErrors func for show validation errors for each invalid fields.funcValidatorErrors(errerror)map[string]string{// Define fields map.fields:=map[string]string{}// Make error message for each invalid field.for_,err:=rangeerr.(validator.ValidationErrors){fields[err.Field()]=err.Error()}returnfields}
So as not to lose performance, I like to work with pure SQL queries without sugar, like gorm or similar packages. It gives a much better understanding of how the application works, which will help in the future not to make silly mistakes, when optimizing database queries!
// ./app/queries/book_query.gopackagequeriesimport("github.com/google/uuid""github.com/jmoiron/sqlx""github.com/koddr/tutorial-go-fiber-rest-api/app/models")// BookQueries struct for queries from Book model.typeBookQueriesstruct{*sqlx.DB}// GetBooks method for getting all books.func(q*BookQueries)GetBooks()([]models.Book,error){// Define books variable.books:=[]models.Book{}// Define query string.query:=`SELECT * FROM books`// Send query to database.err:=q.Get(&books,query)iferr!=nil{// Return empty object and error.returnbooks,err}// Return query result.returnbooks,nil}// GetBook method for getting one book by given ID.func(q*BookQueries)GetBook(iduuid.UUID)(models.Book,error){// Define book variable.book:=models.Book{}// Define query string.query:=`SELECT * FROM books WHERE id = $1`// Send query to database.err:=q.Get(&book,query,id)iferr!=nil{// Return empty object and error.returnbook,err}// Return query result.returnbook,nil}// CreateBook method for creating book by given Book object.func(q*BookQueries)CreateBook(b*models.Book)error{// Define query string.query:=`INSERT INTO books VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`// Send query to database._,err:=q.Exec(query,b.ID,b.CreatedAt,b.UpdatedAt,b.UserID,b.Title,b.Author,b.BookStatus,b.BookAttrs)iferr!=nil{// Return only error.returnerr}// This query returns nothing.returnnil}// UpdateBook method for updating book by given Book object.func(q*BookQueries)UpdateBook(iduuid.UUID,b*models.Book)error{// Define query string.query:=`UPDATE books SET updated_at = $2, title = $3, author = $4, book_status = $5, book_attrs = $6 WHERE id = $1`// Send query to database._,err:=q.Exec(query,id,b.UpdatedAt,b.Title,b.Author,b.BookStatus,b.BookAttrs)iferr!=nil{// Return only error.returnerr}// This query returns nothing.returnnil}// DeleteBook method for delete book by given ID.func(q*BookQueries)DeleteBook(iduuid.UUID)error{// Define query string.query:=`DELETE FROM books WHERE id = $1`// Send query to database._,err:=q.Exec(query,id)iferr!=nil{// Return only error.returnerr}// This query returns nothing.returnnil}
Create model controllers
The principle of the GET methods:
Make request to the API endpoint;
Make a connection to the database (or an error);
Make a query to get record(s) from the table books (or an error);
Return the status 200 and JSON with a founded book(s);
// ./app/controllers/book_controller.gopackagecontrollersimport("time""github.com/gofiber/fiber/v2""github.com/google/uuid""github.com/koddr/tutorial-go-fiber-rest-api/app/models""github.com/koddr/tutorial-go-fiber-rest-api/pkg/utils""github.com/koddr/tutorial-go-fiber-rest-api/platform/database")// GetBooks func gets all exists books.// @Description Get all exists books.// @Summary get all exists books// @Tags Books// @Accept json// @Produce json// @Success 200 {array} models.Book// @Router /v1/books [get]funcGetBooks(c*fiber.Ctx)error{// Create database connection.db,err:=database.OpenDBConnection()iferr!=nil{// Return status 500 and database connection error.returnc.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error":true,"msg":err.Error(),})}// Get all books.books,err:=db.GetBooks()iferr!=nil{// Return, if books not found.returnc.Status(fiber.StatusNotFound).JSON(fiber.Map{"error":true,"msg":"books were not found","count":0,"books":nil,})}// Return status 200 OK.returnc.JSON(fiber.Map{"error":false,"msg":nil,"count":len(books),"books":books,})}// GetBook func gets book by given ID or 404 error.// @Description Get book by given ID.// @Summary get book by given ID// @Tags Book// @Accept json// @Produce json// @Param id path string true "Book ID"// @Success 200 {object} models.Book// @Router /v1/book/{id} [get]funcGetBook(c*fiber.Ctx)error{// Catch book ID from URL.id,err:=uuid.Parse(c.Params("id"))iferr!=nil{returnc.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error":true,"msg":err.Error(),})}// Create database connection.db,err:=database.OpenDBConnection()iferr!=nil{// Return status 500 and database connection error.returnc.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error":true,"msg":err.Error(),})}// Get book by ID.book,err:=db.GetBook(id)iferr!=nil{// Return, if book not found.returnc.Status(fiber.StatusNotFound).JSON(fiber.Map{"error":true,"msg":"book with the given ID is not found","book":nil,})}// Return status 200 OK.returnc.JSON(fiber.Map{"error":false,"msg":nil,"book":book,})}// ...
The principle of the POST methods:
Make a request to the API endpoint;
Check, if request Header has a valid JWT;
Check, if expire date from JWT greather than now (or an error);
Parse Body of request and bind fields to the Book struct (or an error);
Make a connection to the database (or an error);
Validate struct fields with a new content from Body (or an error);
Make a query to create a new record in the table books (or an error);
Return the status 200 and JSON with a new book;
// ...// CreateBook func for creates a new book.// @Description Create a new book.// @Summary create a new book// @Tags Book// @Accept json// @Produce json// @Param title body string true "Title"// @Param author body string true "Author"// @Param book_attrs body models.BookAttrs true "Book attributes"// @Success 200 {object} models.Book// @Security ApiKeyAuth// @Router /v1/book [post]funcCreateBook(c*fiber.Ctx)error{// Get now time.now:=time.Now().Unix()// Get claims from JWT.claims,err:=utils.ExtractTokenMetadata(c)iferr!=nil{// Return status 500 and JWT parse error.returnc.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error":true,"msg":err.Error(),})}// Set expiration time from JWT data of current book.expires:=claims.Expires// Checking, if now time greather than expiration from JWT.ifnow>expires{// Return status 401 and unauthorized error message.returnc.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error":true,"msg":"unauthorized, check expiration time of your token",})}// Create new Book structbook:=&models.Book{}// Check, if received JSON data is valid.iferr:=c.BodyParser(book);err!=nil{// Return status 400 and error message.returnc.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error":true,"msg":err.Error(),})}// Create database connection.db,err:=database.OpenDBConnection()iferr!=nil{// Return status 500 and database connection error.returnc.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error":true,"msg":err.Error(),})}// Create a new validator for a Book model.validate:=utils.NewValidator()// Set initialized default data for book:book.ID=uuid.New()book.CreatedAt=time.Now()book.BookStatus=1// 0 == draft, 1 == active// Validate book fields.iferr:=validate.Struct(book);err!=nil{// Return, if some fields are not valid.returnc.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error":true,"msg":utils.ValidatorErrors(err),})}// Delete book by given ID.iferr:=db.CreateBook(book);err!=nil{// Return status 500 and error message.returnc.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error":true,"msg":err.Error(),})}// Return status 200 OK.returnc.JSON(fiber.Map{"error":false,"msg":nil,"book":book,})}// ...
The principle of the PUT methods:
Make a request to the API endpoint;
Check, if request Header has a valid JWT;
Check, if expire date from JWT greather than now (or an error);
Parse Body of request and bind fields to the Book struct (or an error);
Make a connection to the database (or an error);
Validate struct fields with a new content from Body (or an error);
Check, if book with this ID is exists (or an error);
Make a query to update this record in the table books (or an error);
Return the status 201 without content;
// ...// UpdateBook func for updates book by given ID.// @Description Update book.// @Summary update book// @Tags Book// @Accept json// @Produce json// @Param id body string true "Book ID"// @Param title body string true "Title"// @Param author body string true "Author"// @Param book_status body integer true "Book status"// @Param book_attrs body models.BookAttrs true "Book attributes"// @Success 201 {string} status "ok"// @Security ApiKeyAuth// @Router /v1/book [put]funcUpdateBook(c*fiber.Ctx)error{// Get now time.now:=time.Now().Unix()// Get claims from JWT.claims,err:=utils.ExtractTokenMetadata(c)iferr!=nil{// Return status 500 and JWT parse error.returnc.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error":true,"msg":err.Error(),})}// Set expiration time from JWT data of current book.expires:=claims.Expires// Checking, if now time greather than expiration from JWT.ifnow>expires{// Return status 401 and unauthorized error message.returnc.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error":true,"msg":"unauthorized, check expiration time of your token",})}// Create new Book structbook:=&models.Book{}// Check, if received JSON data is valid.iferr:=c.BodyParser(book);err!=nil{// Return status 400 and error message.returnc.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error":true,"msg":err.Error(),})}// Create database connection.db,err:=database.OpenDBConnection()iferr!=nil{// Return status 500 and database connection error.returnc.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error":true,"msg":err.Error(),})}// Checking, if book with given ID is exists.foundedBook,err:=db.GetBook(book.ID)iferr!=nil{// Return status 404 and book not found error.returnc.Status(fiber.StatusNotFound).JSON(fiber.Map{"error":true,"msg":"book with this ID not found",})}// Set initialized default data for book:book.UpdatedAt=time.Now()// Create a new validator for a Book model.validate:=utils.NewValidator()// Validate book fields.iferr:=validate.Struct(book);err!=nil{// Return, if some fields are not valid.returnc.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error":true,"msg":utils.ValidatorErrors(err),})}// Update book by given ID.iferr:=db.UpdateBook(foundedBook.ID,book);err!=nil{// Return status 500 and error message.returnc.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error":true,"msg":err.Error(),})}// Return status 201.returnc.SendStatus(fiber.StatusCreated)}// ...
The principle of the DELETE methods:
Make a request to the API endpoint;
Check, if request Header has a valid JWT;
Check, if expire date from JWT greather than now (or an error);
Parse Body of request and bind fields to the Book struct (or an error);
Make a connection to the database (or an error);
Validate struct fields with a new content from Body (or an error);
Check, if book with this ID is exists (or an error);
Make a query to delete this record from the table books (or an error);
Return the status 204 without content;
// ...// DeleteBook func for deletes book by given ID.// @Description Delete book by given ID.// @Summary delete book by given ID// @Tags Book// @Accept json// @Produce json// @Param id body string true "Book ID"// @Success 204 {string} status "ok"// @Security ApiKeyAuth// @Router /v1/book [delete]funcDeleteBook(c*fiber.Ctx)error{// Get now time.now:=time.Now().Unix()// Get claims from JWT.claims,err:=utils.ExtractTokenMetadata(c)iferr!=nil{// Return status 500 and JWT parse error.returnc.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error":true,"msg":err.Error(),})}// Set expiration time from JWT data of current book.expires:=claims.Expires// Checking, if now time greather than expiration from JWT.ifnow>expires{// Return status 401 and unauthorized error message.returnc.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error":true,"msg":"unauthorized, check expiration time of your token",})}// Create new Book structbook:=&models.Book{}// Check, if received JSON data is valid.iferr:=c.BodyParser(book);err!=nil{// Return status 400 and error message.returnc.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error":true,"msg":err.Error(),})}// Create a new validator for a Book model.validate:=utils.NewValidator()// Validate only one book field ID.iferr:=validate.StructPartial(book,"id");err!=nil{// Return, if some fields are not valid.returnc.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error":true,"msg":utils.ValidatorErrors(err),})}// Create database connection.db,err:=database.OpenDBConnection()iferr!=nil{// Return status 500 and database connection error.returnc.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error":true,"msg":err.Error(),})}// Checking, if book with given ID is exists.foundedBook,err:=db.GetBook(book.ID)iferr!=nil{// Return status 404 and book not found error.returnc.Status(fiber.StatusNotFound).JSON(fiber.Map{"error":true,"msg":"book with this ID not found",})}// Delete book by given ID.iferr:=db.DeleteBook(foundedBook.ID);err!=nil{// Return status 500 and error message.returnc.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error":true,"msg":err.Error(),})}// Return status 204 no content.returnc.SendStatus(fiber.StatusNoContent)}
Method for get a new Access token (JWT)
Make request to the API endpoint;
Return the status 200 and JSON with a new access token;
// ./app/controllers/token_controller.gopackagecontrollersimport("github.com/gofiber/fiber/v2""github.com/koddr/tutorial-go-fiber-rest-api/pkg/utils")// GetNewAccessToken method for create a new access token.// @Description Create a new access token.// @Summary create a new access token// @Tags Token// @Accept json// @Produce json// @Success 200 {string} status "ok"// @Router /v1/token/new [get]funcGetNewAccessToken(c*fiber.Ctx)error{// Generate a new Access token.token,err:=utils.GenerateNewAccessToken()iferr!=nil{// Return status 500 and token generation error.returnc.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error":true,"msg":err.Error(),})}returnc.JSON(fiber.Map{"error":false,"msg":nil,"access_token":token,})}
This is the most important feature in our entire application. It loads the configuration from the .env file, defines the Swagger settings, creates a new Fiber instance, connects the necessary groups of endpoints and starts the API server.
// ./main.gopackagemainimport("github.com/gofiber/fiber/v2""github.com/koddr/tutorial-go-fiber-rest-api/pkg/configs""github.com/koddr/tutorial-go-fiber-rest-api/pkg/middleware""github.com/koddr/tutorial-go-fiber-rest-api/pkg/routes""github.com/koddr/tutorial-go-fiber-rest-api/pkg/utils"_"github.com/joho/godotenv/autoload"// load .env file automatically_"github.com/koddr/tutorial-go-fiber-rest-api/docs"// load API Docs files (Swagger))// @title API// @version 1.0// @description This is an auto-generated API Docs.// @termsOfService http://swagger.io/terms/// @contact.name API Support// @contact.email your@mail.com// @license.name Apache 2.0// @license.url http://www.apache.org/licenses/LICENSE-2.0.html// @securityDefinitions.apikey ApiKeyAuth// @in header// @name Authorization// @BasePath /apifuncmain(){// Define Fiber config.config:=configs.FiberConfig()// Define a new Fiber app with config.app:=fiber.New(config)// Middlewares.middleware.FiberMiddleware(app)// Register Fiber's middleware for app.// Routes.routes.SwaggerRoute(app)// Register a route for API Docs (Swagger).routes.PublicRoutes(app)// Register a public routes for app.routes.PrivateRoutes(app)// Register a private routes for app.routes.NotFoundRoute(app)// Register route for 404 Error.// Start server (with graceful shutdown).utils.StartServerWithGracefulShutdown(app)}
Since in this application I want to show how to use JWT to authorize some queries, we need to write additional middleware to validate it:
// ./pkg/middleware/jwt_middleware.gopackagemiddlewareimport("os""github.com/gofiber/fiber/v2"jwtMiddleware"github.com/gofiber/jwt/v2")// JWTProtected func for specify routes group with JWT authentication.// See: https://github.com/gofiber/jwtfuncJWTProtected()func(*fiber.Ctx)error{// Create config for JWT authentication middleware.config:=jwtMiddleware.Config{SigningKey:[]byte(os.Getenv("JWT_SECRET_KEY")),ContextKey:"jwt",// used in private routesErrorHandler:jwtError,}returnjwtMiddleware.New(config)}funcjwtError(c*fiber.Ctx,errerror)error{// Return status 401 and failed authentication error.iferr.Error()=="Missing or malformed JWT"{returnc.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error":true,"msg":err.Error(),})}// Return status 401 and failed authentication error.returnc.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error":true,"msg":err.Error(),})}
// ./pkg/routes/private_routes.gopackageroutesimport("github.com/gofiber/fiber/v2""github.com/koddr/tutorial-go-fiber-rest-api/app/controllers")// PublicRoutes func for describe group of public routes.funcPublicRoutes(a*fiber.App){// Create routes group.route:=a.Group("/api/v1")// Routes for GET method:route.Get("/books",controllers.GetBooks)// get list of all booksroute.Get("/book/:id",controllers.GetBook)// get one book by IDroute.Get("/token/new",controllers.GetNewAccessToken)// create a new access tokens}
For private (JWT protected) methods:
// ./pkg/routes/private_routes.gopackageroutesimport("github.com/gofiber/fiber/v2""github.com/koddr/tutorial-go-fiber-rest-api/app/controllers""github.com/koddr/tutorial-go-fiber-rest-api/pkg/middleware")// PrivateRoutes func for describe group of private routes.funcPrivateRoutes(a*fiber.App){// Create routes group.route:=a.Group("/api/v1")// Routes for POST method:route.Post("/book",middleware.JWTProtected(),controllers.CreateBook)// create a new book// Routes for PUT method:route.Put("/book",middleware.JWTProtected(),controllers.UpdateBook)// update one book by ID// Routes for DELETE method:route.Delete("/book",middleware.JWTProtected(),controllers.DeleteBook)// delete one book by ID}
For Swagger:
// ./pkg/routes/swagger_route.gopackageroutesimport("github.com/gofiber/fiber/v2"swagger"github.com/arsmn/fiber-swagger/v2")// SwaggerRoute func for describe group of API Docs routes.funcSwaggerRoute(a*fiber.App){// Create routes group.route:=a.Group("/swagger")// Routes for GET method:route.Get("*",swagger.Handler)// get one user by ID}
Not found (404) route:
// ./pkg/routes/not_found_route.gopackageroutesimport"github.com/gofiber/fiber/v2"// NotFoundRoute func for describe 404 Error route.funcNotFoundRoute(a*fiber.App){// Register new special route.a.Use(// Anonimus function.func(c*fiber.Ctx)error{// Return HTTP 404 status and JSON response.returnc.Status(fiber.StatusNotFound).JSON(fiber.Map{"error":true,"msg":"sorry, endpoint is not found",})},)}
The database connection is the most important part of this application (as well as any other, to be honest). I like to break this process down into two parts.
The method for the connection:
// ./platform/database/open_db_connection.gopackagedatabaseimport"github.com/koddr/tutorial-go-fiber-rest-api/app/queries"// Queries struct for collect all app queries.typeQueriesstruct{*queries.BookQueries// load queries from Book model}// OpenDBConnection func for opening database connection.funcOpenDBConnection()(*Queries,error){// Define a new PostgreSQL connection.db,err:=PostgreSQLConnection()iferr!=nil{returnnil,err}return&Queries{// Set queries from models:BookQueries:&queries.BookQueries{DB:db},// from Book model},nil}
The specific connection settings for the selected database:
// ./platform/database/postgres.gopackagedatabaseimport("fmt""os""strconv""time""github.com/jmoiron/sqlx"_"github.com/jackc/pgx/v4/stdlib"// load pgx driver for PostgreSQL)// PostgreSQLConnection func for connection to PostgreSQL database.funcPostgreSQLConnection()(*sqlx.DB,error){// Define database connection settings.maxConn,_:=strconv.Atoi(os.Getenv("DB_MAX_CONNECTIONS"))maxIdleConn,_:=strconv.Atoi(os.Getenv("DB_MAX_IDLE_CONNECTIONS"))maxLifetimeConn,_:=strconv.Atoi(os.Getenv("DB_MAX_LIFETIME_CONNECTIONS"))// Define database connection for PostgreSQL.db,err:=sqlx.Connect("pgx",os.Getenv("DB_SERVER_URL"))iferr!=nil{returnnil,fmt.Errorf("error, not connected to database, %w",err)}// Set database connection settings.db.SetMaxOpenConns(maxConn)// the default is 0 (unlimited)db.SetMaxIdleConns(maxIdleConn)// defaultMaxIdleConns = 2db.SetConnMaxLifetime(time.Duration(maxLifetimeConn))// 0, connections are reused forever// Try to ping database.iferr:=db.Ping();err!=nil{deferdb.Close()// close database connectionreturnnil,fmt.Errorf("error, not sent ping to database, %w",err)}returndb,nil}
โ๏ธ This approach helps to connect additional databases more easily if required and always keep a clear hierarchy of data storage in the application.
For start API server (with a graceful shutdown or simple for dev):
// ./pkg/utils/start_server.gopackageutilsimport("log""os""os/signal""github.com/gofiber/fiber/v2")// StartServerWithGracefulShutdown function for starting server with a graceful shutdown.funcStartServerWithGracefulShutdown(a*fiber.App){// Create channel for idle connections.idleConnsClosed:=make(chanstruct{})gofunc(){sigint:=make(chanos.Signal,1)signal.Notify(sigint,os.Interrupt)// Catch OS signals.<-sigint// Received an interrupt signal, shutdown.iferr:=a.Shutdown();err!=nil{// Error from closing listeners, or context timeout:log.Printf("Oops... Server is not shutting down! Reason: %v",err)}close(idleConnsClosed)}()// Run server.iferr:=a.Listen(os.Getenv("SERVER_URL"));err!=nil{log.Printf("Oops... Server is not running! Reason: %v",err)}<-idleConnsClosed}// StartServer func for starting a simple server.funcStartServer(a*fiber.App){// Run server.iferr:=a.Listen(os.Getenv("SERVER_URL"));err!=nil{log.Printf("Oops... Server is not running! Reason: %v",err)}}
For generate a valid JWT:
// ./pkg/utils/jwt_generator.gopackageutilsimport("os""strconv""time""github.com/golang-jwt/jwt")// GenerateNewAccessToken func for generate a new Access token.funcGenerateNewAccessToken()(string,error){// Set secret key from .env file.secret:=os.Getenv("JWT_SECRET_KEY")// Set expires minutes count for secret key from .env file.minutesCount,_:=strconv.Atoi(os.Getenv("JWT_SECRET_KEY_EXPIRE_MINUTES_COUNT"))// Create a new claims.claims:=jwt.MapClaims{}// Set public claims:claims["exp"]=time.Now().Add(time.Minute*time.Duration(minutesCount)).Unix()// Create a new JWT access token with claims.token:=jwt.NewWithClaims(jwt.SigningMethodHS256,claims)// Generate token.t,err:=token.SignedString([]byte(secret))iferr!=nil{// Return error, it JWT token generation failed.return"",err}returnt,nil}
For parse and validate JWT:
// ./pkg/utils/jwt_parser.gopackageutilsimport("os""strings""github.com/golang-jwt/jwt""github.com/gofiber/fiber/v2")// TokenMetadata struct to describe metadata in JWT.typeTokenMetadatastruct{Expiresint64}// ExtractTokenMetadata func to extract metadata from JWT.funcExtractTokenMetadata(c*fiber.Ctx)(*TokenMetadata,error){token,err:=verifyToken(c)iferr!=nil{returnnil,err}// Setting and checking token and credentials.claims,ok:=token.Claims.(jwt.MapClaims)ifok&&token.Valid{// Expires time.expires:=int64(claims["exp"].(float64))return&TokenMetadata{Expires:expires,},nil}returnnil,err}funcextractToken(c*fiber.Ctx)string{bearToken:=c.Get("Authorization")// Normally Authorization HTTP header.onlyToken:=strings.Split(bearToken," ")iflen(onlyToken)==2{returnonlyToken[1]}return""}funcverifyToken(c*fiber.Ctx)(*jwt.Token,error){tokenString:=extractToken(c)token,err:=jwt.Parse(tokenString,jwtKeyFunc)iferr!=nil{returnnil,err}returntoken,nil}funcjwtKeyFunc(token*jwt.Token)(interface{},error){return[]byte(os.Getenv("JWT_SECRET_KEY")),nil}
So, we're getting to the most important stage! Let's check our Fiber application through testing. I'll show you the principle by testing private routes (JWT protected).
โ๏ธ As always, I will use Fiber's built-in Test() method and an awesome package stretchr/testify for testing Golang apps.
Also, I like to put the configuration for testing in a separate file, I don't want to mix a production config with a test config. So, I use the file called .env.test, which I will add to the root of the project.
Pay attention to the part of the code where routes are defined. We're calling the real routes of our application, so before running the test, you need to bring up the database (e.g. in a Docker container for simplicity).
// ./pkg/routes/private_routes_test.gopackageroutesimport("io""net/http/httptest""strings""testing""github.com/gofiber/fiber/v2""github.com/joho/godotenv""github.com/koddr/tutorial-go-fiber-rest-api/pkg/utils""github.com/stretchr/testify/assert")funcTestPrivateRoutes(t*testing.T){// Load .env.test file from the root folder.iferr:=godotenv.Load("../../.env.test");err!=nil{panic(err)}// Create a sample data string.dataString:=`{"id": "00000000-0000-0000-0000-000000000000"}`// Create access token.token,err:=utils.GenerateNewAccessToken()iferr!=nil{panic(err)}// Define a structure for specifying input and output data of a single test case.tests:=[]struct{descriptionstringroutestring// input routemethodstring// input methodtokenStringstring// input tokenbodyio.ReaderexpectedErrorboolexpectedCodeint}{{description:"delete book without JWT and body",route:"/api/v1/book",method:"DELETE",tokenString:"",body:nil,expectedError:false,expectedCode:400,},{description:"delete book without right credentials",route:"/api/v1/book",method:"DELETE",tokenString:"Bearer "+token,body:strings.NewReader(dataString),expectedError:false,expectedCode:403,},{description:"delete book with credentials",route:"/api/v1/book",method:"DELETE",tokenString:"Bearer "+token,body:strings.NewReader(dataString),expectedError:false,expectedCode:404,},}// Define a new Fiber app.app:=fiber.New()// Define routes.PrivateRoutes(app)// Iterate through test single test casesfor_,test:=rangetests{// Create a new http request with the route from the test case.req:=httptest.NewRequest(test.method,test.route,test.body)req.Header.Set("Authorization",test.tokenString)req.Header.Set("Content-Type","application/json")// Perform the request plain with the app.resp,err:=app.Test(req,-1)// the -1 disables request latency// Verify, that no error occurred, that is not expectedassert.Equalf(t,test.expectedError,err!=nil,test.description)// As expected errors lead to broken responses,// the next test case needs to be processed.iftest.expectedError{continue}// Verify, if the status code is as expected.assert.Equalf(t,test.expectedCode,resp.StatusCode,test.description)}}// ...
For further (independent) development of this application, I recommend considering the following options:
Upgrade the CreateBook method: add a handler to save picture to a cloud storage service (e.g., Amazon S3 or similar) and save only picture ID to our database;
Upgrade the GetBook and GetBooks methods: add a handler to change picture ID from a cloud service to direct link to this picture;
Add a new method for registering new users (e.g., registered users can get a role, which will allow them to perform some methods in the REST API);
Add a new method for user authorization (e.g., after authorization, users receive a JWT token that contains credentials according to its role);
Add a standalone container with Redis (or similar) to store the sessions of these authorized users;
If you want more articles (like this) on this blog, then post a comment below and subscribe to me. Thanks! ๐ป
โ๏ธ You can support me on Boosty, both on a permanent and on a one-time basis. All proceeds from this way will go to support my OSS projects and will energize me to create new products and articles for the community.
And of course, you can help me make developers' lives even better! Just connect to one of my projects as a contributor. It's easy!
My main projects that need your help (and stars) ๐
๐ฅ gowebly: A next-generation CLI tool that makes it easy to create amazing web applications with Go on the backend, using htmx, hyperscript or Alpine.js and the most popular CSS frameworks on the frontend.
โจ create-go-app: Create a new production-ready project with Go backend, frontend and deploy automation by running one CLI command.