所有文章 > 日积月累 > 在Go上构建RESTful API: Fiber, PostgreSQL, JWT和Swagge文档
在Go上构建RESTful API: Fiber, PostgreSQL, JWT和Swagge文档

在Go上构建RESTful API: Fiber, PostgreSQL, JWT和Swagge文档

大家好!欢迎来到一个极其精彩的教程。本教程旨在通过贴近现实生活的应用程序示例,为您提供清晰易懂的分步指导,让您能够即刻将所学知识付诸实践。

我特意避免将本教程分割成几个相互独立的部分,以确保您能够保持清晰的思路和抓住重点。毕竟,我编写本教程只是为了分享我的经验并表明使用 Fiber 框架在 Golang 中进行后端开发很容易!

在教程的最后,您将找到一个自查知识块,以及进一步开发应用程序的计划。因此,我建议您将本教程的链接保存到您的书签中,并在您的社交网络上分享。

我们想要建造什么?

让我们为在线图书馆应用程序创建一个 REST API,在其中创建新书籍、查看它们以及更新和删除它们的信息。但有些方法需要我们通过提供有效的 JWT 访问令牌进行授权。我会像往常一样,把所有关于书籍的信息都保存在我心爱的PostgreSQL数据库中。

我认为,这个功能已经足够让您了解到,使用Fiber Web框架在Go语言中创建REST API是多么简便易行。

API方法

公众:

  • GET : /api/v1/books,获取所有书籍;
  • GET : /api/v1/book/{id},通过给定 ID 获取图书;
  • GET :/api/v1/token/new,创建一个新的访问令牌(用于演示);

私有(受 JWT 保护):

  • POST : /api/v1/book,创建一本新书;
  • PATCH : /api/v1/book,更新现有书籍;
  • DELETE : /api/v1/book,删除已有的图书;

教程:在 Go 上构建 RESTful API

Fiber、PostgreSQL、JWT 以及 Swagger 文档都被部署在独立的 Docker 容器中。

快速启动

  1. 重命名.env.example.env并填充您的环境值。
  2. 安装Docker和迁移工具以应用迁移。

我采用的Go项目架构方法

过去两年里,我尝试了多种Go应用程序结构,最终选定了我现在的架构,接下来我会尝试向您解释这一选择。

仅包含业务逻辑的文件夹

./app 文件夹并不关心您具体使用了哪种数据库驱动程序、选择了哪种缓存解决方案,或是其他任何第三方工具。

  • ./app/controllers功能控制器的文件夹;
  • ./app/models用于描述业务模型和方法的文件夹;
  • ./app/queries用于描述模型查询的文件夹;

包含 API 文档的文件夹

./docs文件夹包含 Swagger 自动生成的 API 文档的配置文件。

具有项目特定功能的文件夹

./pkg 文件夹包含了所有专为您的业务用例定制的项目特定代码,例如configs、middleware)、paths或utility等。

  • ./pkg/configs配置功能文件夹;
  • ./pkg/middleware用于添加中间件的文件夹
  • ./pkg/routes用于描述项目路线的文件夹;
  • ./pkg/utils具有实用功能的文件夹(服务器启动器、生成器等);

具有平台级逻辑的文件夹

./platform文件夹包含将构建实际项目的所有平台级逻辑,例如设置数据库或缓存服务器实例以及存储迁移。

  • ./platform/database具有数据库设置功能的文件夹;
  • ./platform/migrations包含迁移文件的文件夹;

项目配置

乍一看,这个项目的配置可能会显得有些复杂。别担心,我会尽可能简单易懂地描述每一点。

生成文件

我强烈建议使用Makefile来加快项目管理速度!不过,在这篇文章中,我希望能详细展示整个过程。因此,我会直接列出所有命令,而不是依赖某种“魔法”的 make 工具。

ENV 文件中的光纤配置

我知道有些人倾向于使用 YML 文件来配置他们的 Go 应用程序,但我个人更习惯于使用经典的 .env 配置方式,而且我并没有发现 YML 配置能带来特别大的优势。尽管我写了一篇关于 Go 中此类应用程序配置的文章)在过去)。

该项目的配置文件如下:

# ./.env

# Server settings:
SERVER_URL="0.0.0.0:5000"
SERVER_READ_TIMEOUT=60

# JWT settings:
JWT_SECRET_KEY="secret"
JWT_SECRET_KEY_EXPIRE_MINUTES_COUNT=15

# Database settings:
DB_SERVER_URL="host=localhost port=5432 user=postgres password=password dbname=postgres sslmode=disable"
DB_MAX_CONNECTIONS=100
DB_MAX_IDLE_CONNECTIONS=10
DB_MAX_LIFETIME_CONNECTIONS=2

Docker网络

为您的操作系统安装并运行Docker服务。顺便提一下,在本教程中我使用的是最新版本(截至当前)v20.10.2。

好的,让我们创建一个新的 Docker 网络,名为dev-network

docker network create -d bridge dev-network

将来,当我们在独立的容器中分别运行数据库和Fiber实例时,会用到这个配置。如果不这样做,两个容器将无法相互通信。

PostgreSQL 和初始迁移

那么,让我们用数据库启动容器:

docker run --rm -d \
--name dev-postgres \
--network dev-network \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=password \
-e POSTGRES_DB=postgres \
-v ${HOME}/dev-postgres/data/:/var/lib/postgresql/data \
-p 5432:5432 \
postgres

太棒了!现在我们准备将原始结构迁移到新的架构中。这是用于迁移的文件up,名为000001_create_init_tables.up.sql

-- ./platform/migrations/000001_create_init_tables.up.sql

-- Add UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

-- Set timezone
-- For more information, please visit:
-- https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
SET TIMEZONE="Europe/Moscow";

-- Create books table
CREATE TABLE books (
id UUID DEFAULT uuid_generate_v4 () PRIMARY KEY,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW (),
updated_at TIMESTAMP NULL,
title VARCHAR (255) NOT NULL,
author VARCHAR (255) NOT NULL,
book_status INT NOT NULL,
book_attrs JSONB NOT NULL
);

-- Add indexes
CREATE INDEX active_books ON books (title) WHERE book_status = 1;

☝️ 为了轻松使用其他书籍属性,我使用JSONB类型作为book_attrs字段。

对于 000001_create_init_tables.down.sql 滚动迁移脚本:

-- ./platform/migrations/000001_create_init_tables.down.sql

-- Delete tables
DROP TABLE IF EXISTS books;

好的!我们可以滚动此迁移。

👍 我建议使用golang-migrate/migrate工具,通过一个控制台命令轻松上下数据库迁移。

migrate \
-path $(PWD)/platform/migrations \
-database "postgres://postgres:password@localhost/postgres?sslmode=disable" \
up

Fiber 应用程序的 Dockerfile

在项目根文件夹中创建一个 Dockerfile

# ./Dockerfile

FROM golang:1.16-alpine AS builder

# 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=amd64
RUN 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"]

您说得对,您正在采用两阶段容器构建方式,并使用 Golang 1.16.x 版本。在构建应用程序时,通过设置 CGO_ENABLED=0 和 -ldflags="-s -w" 参数来减小最终生成的二进制文件的大小。否则,这对于任何 Go 项目来说都是最常见的Dockerfile,您可以在任何地方使用。

构建 Fiber Docker 镜像的命令:

docker build -t fiber .

☝️ 不要忘记将.dockerignore文件添加到项目的根文件夹中,其中包含所有文件和文件夹,创建容器时应忽略该文件。

从镜像创建并启动容器的命令:

docker run --rm -d \
--name dev-fiber \
--network dev-network \
-p 5000:5000 \
fiber

工具

就像您从标题中可能猜测到的那样,我们不会太过关注记录API方法的事情。仅仅因为有像Swagger这样出色的工具可以为我们完成所有工作!

  • swaggo/swag包,用于在 Go 中轻松生成 Swagger 配置;
  • arsmn/ Fiber-swagger官方 Fiber 的中间件;

实用部分

好了,我们已经准备好了所有必要的配置文件和工作环境,我们知道我们要创建什么。现在是时候打开我们最喜欢的 IDE 并开始编写代码了。

👋 请注意,我将在代码的注释中直接解释一些关键点,而不是在文章正文中进行说明。

创建模型

在实现模型之前,我总是创建一个具有 SQL 结构的迁移文件(来自第 3 章)。这使得一次呈现所有必要的模型字段变得更加容易。

// ./app/models/book_model.go

package models

import (
"database/sql/driver"
"encoding/json"
"errors"
"time"

"github.com/google/uuid"
)

// Book struct to describe book object.
type Book struct {
ID uuid.UUID `db:"id" json:"id" validate:"required,uuid"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
UserID uuid.UUID `db:"user_id" json:"user_id" validate:"required,uuid"`
Title string `db:"title" json:"title" validate:"required,lte=255"`
Author string `db:"author" json:"author" validate:"required,lte=255"`
BookStatus int `db:"book_status" json:"book_status" validate:"required,len=1"`
BookAttrs BookAttrs `db:"book_attrs" json:"book_attrs" validate:"required,dive"`
}

// BookAttrs struct to describe book attributes.
type BookAttrs struct {
Picture string `json:"picture"`
Description string `json:"description"`
Rating int `json:"rating" validate:"min=1,max=10"`
}

// ...

👍 我建议使用 google/uuid 包来生成唯一的ID,因为这是一种更为通用的做法,可以有效地增强您的应用程序对于常见数字暴力攻击(如猜测ID)的防护能力。

但这还不是全部。您需要编写两个特殊方法:

  1. Value(),用于返回结构体的 JSON 编码表示形式;
  2. Scan(),用于将 JSON 编码值解码到结构字段中;

它们可能看起来像这样:

// ...

// Value make the BookAttrs struct implement the driver.Valuer interface.
// This method simply returns the JSON-encoded representation of the struct.
func (b BookAttrs) Value() (driver.Value, error) {
return json.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(value interface{}) error {
j, ok := value.([]byte)
if !ok {
return errors.New("type assertion to []byte failed")
}

return json.Unmarshal(j, &b)
}

为模型字段创建验证器

好的,让我们定义在将输入传递给控制器​​业务逻辑之前需要检查的字段:

  • ID字段,用于检查有效的UUID;

这些字段是我们最关心的,因为在某些场景下它们会从用户那里来到我们这里。顺便说一句,这就是为什么我们不仅验证输入数据的有效性,而且还将它们视为必需项(required)的原因。

这就是我实现验证器的方式:

// ./app/utils/validator.go

package utils

import (
"github.com/go-playground/validator/v10"
"github.com/google/uuid"
)

// NewValidator func for create a new validator for model fields.
func NewValidator() *validator.Validate {
// Create a new validator for a Book model.
validate := validator.New()

// Custom validation for uuid.UUID fields.
_ = validate.RegisterValidation("uuid", func(fl validator.FieldLevel) bool {
field := fl.Field().String()
if _, err := uuid.Parse(field); err != nil {
return true
}
return false
})

return validate
}

// ValidatorErrors func for show validation errors for each invalid fields.
func ValidatorErrors(err error) map[string]string {
// Define fields map.
fields := map[string]string{}

// Make error message for each invalid field.
for _, err := range err.(validator.ValidationErrors) {
fields[err.Field()] = err.Error()
}

return fields
}

👌 我使用go-playground/validator v10来发布此功能。

创建查询和控制器

数据库查询

为了不损失性能,我倾向于使用不含额外抽象层、类似于原生SQL查询的方式,避免使用那些会增加复杂性和潜在性能开销的ORM(对象关系映射)框架或类似的包。它能够让我们更深入地理解应用程序的工作原理,这将有助于我们在将来优化数据库查询时避免犯下愚蠢的错误!

// ./app/queries/book_query.go

package queries

import (
"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.
type BookQueries struct {
*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)
if err != nil {
// Return empty object and error.
return books, err
}

// Return query result.
return books, nil
}

// GetBook method for getting one book by given ID.
func (q *BookQueries) GetBook(id uuid.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)
if err != nil {
// Return empty object and error.
return book, err
}

// Return query result.
return book, 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)
if err != nil {
// Return only error.
return err
}

// This query returns nothing.
return nil
}

// UpdateBook method for updating book by given Book object.
func (q *BookQueries) UpdateBook(id uuid.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)
if err != nil {
// Return only error.
return err
}

// This query returns nothing.
return nil
}

// DeleteBook method for delete book by given ID.
func (q *BookQueries) DeleteBook(id uuid.UUID) error {
// Define query string.
query := `DELETE FROM books WHERE id = $1`

// Send query to database.
_, err := q.Exec(query, id)
if err != nil {
// Return only error.
return err
}

// This query returns nothing.
return nil
}

创建模型控制器

方法原理:

  • 向API端点发出请求;
  • 建立与数据库的连接(或出现错误);
  • 进行查询以从表中获取记录(或错误);
  • 返回已创建书籍的状态200和 JSON;
// ./app/controllers/book_controller.go

package controllers

import (
"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]
func GetBooks(c *fiber.Ctx) error {
// Create database connection.
db, err := database.OpenDBConnection()
if err != nil {
// Return status 500 and database connection error.
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": true,
"msg": err.Error(),
})
}

// Get all books.
books, err := db.GetBooks()
if err != nil {
// Return, if books not found.
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"error": true,
"msg": "books were not found",
"count": 0,
"books": nil,
})
}

// Return status 200 OK.
return c.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]
func GetBook(c *fiber.Ctx) error {
// Catch book ID from URL.
id, err := uuid.Parse(c.Params("id"))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": true,
"msg": err.Error(),
})
}

// Create database connection.
db, err := database.OpenDBConnection()
if err != nil {
// Return status 500 and database connection error.
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": true,
"msg": err.Error(),
})
}

// Get book by ID.
book, err := db.GetBook(id)
if err != nil {
// Return, if book not found.
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"error": true,
"msg": "book with the given ID is not found",
"book": nil,
})
}

// Return status 200 OK.
return c.JSON(fiber.Map{
"error": false,
"msg": nil,
"book": book,
})
}

// ...

方法原理:

  • 向API端点发出请求;
  • 检查请求是否Header有有效的 JWT;
  • 需要验证JWT的过期日期是否超过了当前时间(否则将视为错误)。
  • 解析请求主体并将字段绑定到 Book 结构(或错误);
  • 建立与数据库的连接(或出现错误);
  • 使用 Body 中的新内容(或错误)验证结构体字段;
  • 进行查询以在表中创建新记录(或出现错误);
  • 返回新书的状态200和JSON;
// ...

// 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]
func CreateBook(c *fiber.Ctx) error {
// Get now time.
now := time.Now().Unix()

// Get claims from JWT.
claims, err := utils.ExtractTokenMetadata(c)
if err != nil {
// Return status 500 and JWT parse error.
return c.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.
if now > expires {
// Return status 401 and unauthorized error message.
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": true,
"msg": "unauthorized, check expiration time of your token",
})
}

// Create new Book struct
book := &models.Book{}

// Check, if received JSON data is valid.
if err := c.BodyParser(book); err != nil {
// Return status 400 and error message.
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"msg": err.Error(),
})
}

// Create database connection.
db, err := database.OpenDBConnection()
if err != nil {
// Return status 500 and database connection error.
return c.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.
if err := validate.Struct(book); err != nil {
// Return, if some fields are not valid.
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"msg": utils.ValidatorErrors(err),
})
}

// Delete book by given ID.
if err := db.CreateBook(book); err != nil {
// Return status 500 and error message.
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": true,
"msg": err.Error(),
})
}

// Return status 200 OK.
return c.JSON(fiber.Map{
"error": false,
"msg": nil,
"book": book,
})
}

// ...

方法原理:

  • 向API端点发出请求;
  • 检查请求是否Header有有效的 JWT;
  • 检查 JWT 的过期日期是否大于现在(或错误);
  • 解析请求主体并将字段绑定到 Book 结构(或错误);
  • 建立与数据库的连接(或出现错误);
  • 根据请求体中的新内容(如果出错则处理错误)来验证结构体字段。
  • 检查该ID的图书是否存在(或错误);
  • 进行查询以更新表中的这条记录或出现错误);
  • 返回无内容的状态201
// ...

// 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]
func UpdateBook(c *fiber.Ctx) error {
// Get now time.
now := time.Now().Unix()

// Get claims from JWT.
claims, err := utils.ExtractTokenMetadata(c)
if err != nil {
// Return status 500 and JWT parse error.
return c.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.
if now > expires {
// Return status 401 and unauthorized error message.
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": true,
"msg": "unauthorized, check expiration time of your token",
})
}

// Create new Book struct
book := &models.Book{}

// Check, if received JSON data is valid.
if err := c.BodyParser(book); err != nil {
// Return status 400 and error message.
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"msg": err.Error(),
})
}

// Create database connection.
db, err := database.OpenDBConnection()
if err != nil {
// Return status 500 and database connection error.
return c.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)
if err != nil {
// Return status 404 and book not found error.
return c.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.
if err := validate.Struct(book); err != nil {
// Return, if some fields are not valid.
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"msg": utils.ValidatorErrors(err),
})
}

// Update book by given ID.
if err := db.UpdateBook(foundedBook.ID, book); err != nil {
// Return status 500 and error message.
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": true,
"msg": err.Error(),
})
}

// Return status 201.
return c.SendStatus(fiber.StatusCreated)
}

// ...

方法原理:

  • 向API端点发出请求;
  • 检查请求是否Header有有效的 JWT;
  • 检查 JWT 的过期日期是否大于现在(或错误);
  • 解析请求主体并将字段绑定到 Book 结构(或错误);
  • 建立与数据库的连接(或出现错误);
  • 使用 Body 中的新内容(或错误)验证结构体字段;
  • 检查该ID的图书是否存在(或错误);
  • 进行查询以从表中删除此记录(或出错);
  • 返回无内容的状态204
// ...

// 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]
func DeleteBook(c *fiber.Ctx) error {
// Get now time.
now := time.Now().Unix()

// Get claims from JWT.
claims, err := utils.ExtractTokenMetadata(c)
if err != nil {
// Return status 500 and JWT parse error.
return c.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.
if now > expires {
// Return status 401 and unauthorized error message.
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": true,
"msg": "unauthorized, check expiration time of your token",
})
}

// Create new Book struct
book := &models.Book{}

// Check, if received JSON data is valid.
if err := c.BodyParser(book); err != nil {
// Return status 400 and error message.
return c.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.
if err := validate.StructPartial(book, "id"); err != nil {
// Return, if some fields are not valid.
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"msg": utils.ValidatorErrors(err),
})
}

// Create database connection.
db, err := database.OpenDBConnection()
if err != nil {
// Return status 500 and database connection error.
return c.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)
if err != nil {
// Return status 404 and book not found error.
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"error": true,
"msg": "book with this ID not found",
})
}

// Delete book by given ID.
if err := db.DeleteBook(foundedBook.ID); err != nil {
// Return status 500 and error message.
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": true,
"msg": err.Error(),
})
}

// Return status 204 no content.
return c.SendStatus(fiber.StatusNoContent)
}

获取新访问令牌 (JWT) 的方法

  • 向API端点发出请求;
  • 使用新的访问令牌返回状态200和 JSON;
// ./app/controllers/token_controller.go

package controllers

import (
"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]
func GetNewAccessToken(c *fiber.Ctx) error {
// Generate a new Access token.
token, err := utils.GenerateNewAccessToken()
if err != nil {
// Return status 500 and token generation error.
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": true,
"msg": err.Error(),
})
}

return c.JSON(fiber.Map{
"error": false,
"msg": nil,
"access_token": token,
})
}

主要功能

这是我们整个应用程序中的核心功能。它负责从 .env 文件中加载配置,设置 Swagger,创建 Fiber 实例,连接所需的端点组,并最终启动 API 服务器。

// ./main.go

package main

import (
"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 /api
func main() {
// 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)
}

中间件功能

由于在这个应用程序中,我希望能展示如何利用JWT对某些查询进行授权,因此我们需要编写一个额外的中间件来进行验证。

// ./pkg/middleware/jwt_middleware.go

package middleware

import (
"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/jwt
func JWTProtected() 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 routes
ErrorHandler: jwtError,
}

return jwtMiddleware.New(config)
}

func jwtError(c *fiber.Ctx, err error) error {
// Return status 401 and failed authentication error.
if err.Error() == "Missing or malformed JWT" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"msg": err.Error(),
})
}

// Return status 401 and failed authentication error.
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": true,
"msg": err.Error(),
})
}

API 端点的路由

  • 对于公共方法:
// ./pkg/routes/private_routes.go

package routes

import (
"github.com/gofiber/fiber/v2"
"github.com/koddr/tutorial-go-fiber-rest-api/app/controllers"
)

// PublicRoutes func for describe group of public routes.
func PublicRoutes(a *fiber.App) {
// Create routes group.
route := a.Group("/api/v1")

// Routes for GET method:
route.Get("/books", controllers.GetBooks) // get list of all books
route.Get("/book/:id", controllers.GetBook) // get one book by ID
route.Get("/token/new", controllers.GetNewAccessToken) // create a new access tokens
}
  • 对于私有(JWT 保护)方法:
// ./pkg/routes/private_routes.go

package routes

import (
"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.
func PrivateRoutes(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
}
  • 对于Swagger:
// ./pkg/routes/swagger_route.go


package routes

import (
"github.com/gofiber/fiber/v2"

swagger "github.com/arsmn/fiber-swagger/v2"
)

// SwaggerRoute func for describe group of API Docs routes.
func SwaggerRoute(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)路线:
// ./pkg/routes/not_found_route.go

package routes

import "github.com/gofiber/fiber/v2"

// NotFoundRoute func for describe 404 Error route.
func NotFoundRoute(a *fiber.App) {
// Register new special route.
a.Use(
// Anonimus function.
func(c *fiber.Ctx) error {
// Return HTTP 404 status and JSON response.
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"error": true,
"msg": "sorry, endpoint is not found",
})
},
)
}

数据库连接

数据库连接无疑是该应用程序中最为关键的一环(事实上,对于任何应用程序而言都是如此)。我倾向于将这个连接过程拆分为两个步骤。

  • 连接方法:
// ./platform/database/open_db_connection.go

package database

import "github.com/koddr/tutorial-go-fiber-rest-api/app/queries"

// Queries struct for collect all app queries.
type Queries struct {
*queries.BookQueries // load queries from Book model
}

// OpenDBConnection func for opening database connection.
func OpenDBConnection() (*Queries, error) {
// Define a new PostgreSQL connection.
db, err := PostgreSQLConnection()
if err != nil {
return nil, err
}

return &Queries{
// Set queries from models:
BookQueries: &queries.BookQueries{DB: db}, // from Book model
}, nil
}
  • 所选数据库的具体连接设置:
// ./platform/database/postgres.go

package database

import (
"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.
func PostgreSQLConnection() (*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"))
if err != nil {
return nil, 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 = 2
db.SetConnMaxLifetime(time.Duration(maxLifetimeConn)) // 0, connections are reused forever

// Try to ping database.
if err := db.Ping(); err != nil {
defer db.Close() // close database connection
return nil, fmt.Errorf("error, not sent ping to database, %w", err)
}

return db, nil
}

☝️ 这种方法有助于在需要时更轻松地连接其他数据库,并始终在应用程序中保持清晰的数据存储层次结构。

高效的实用程序

  • 对于启动 API 服务器(正常关闭或简单的开发):
// ./pkg/utils/start_server.go

package utils

import (
"log"
"os"
"os/signal"

"github.com/gofiber/fiber/v2"
)

// StartServerWithGracefulShutdown function for starting server with a graceful shutdown.
func StartServerWithGracefulShutdown(a *fiber.App) {
// Create channel for idle connections.
idleConnsClosed := make(chan struct{})

go func() {
sigint := make(chan os.Signal, 1)
signal.Notify(sigint, os.Interrupt) // Catch OS signals.
<-sigint

// Received an interrupt signal, shutdown.
if err := 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.
if err := 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.
func StartServer(a *fiber.App) {
// Run server.
if err := a.Listen(os.Getenv("SERVER_URL")); err != nil {
log.Printf("Oops... Server is not running! Reason: %v", err)
}
}
  • 为了生成有效的 JWT:
// ./pkg/utils/jwt_generator.go

package utils

import (
"os"
"strconv"
"time"

"github.com/golang-jwt/jwt"
)

// GenerateNewAccessToken func for generate a new Access token.
func GenerateNewAccessToken() (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))
if err != nil {
// Return error, it JWT token generation failed.
return "", err
}

return t, nil
}
  • 用于解析和验证 JWT:
// ./pkg/utils/jwt_parser.go

package utils

import (
"os"
"strings"

"github.com/golang-jwt/jwt"
"github.com/gofiber/fiber/v2"
)

// TokenMetadata struct to describe metadata in JWT.
type TokenMetadata struct {
Expires int64
}

// ExtractTokenMetadata func to extract metadata from JWT.
func ExtractTokenMetadata(c *fiber.Ctx) (*TokenMetadata, error) {
token, err := verifyToken(c)
if err != nil {
return nil, err
}

// Setting and checking token and credentials.
claims, ok := token.Claims.(jwt.MapClaims)
if ok && token.Valid {
// Expires time.
expires := int64(claims["exp"].(float64))

return &TokenMetadata{
Expires: expires,
}, nil
}

return nil, err
}

func extractToken(c *fiber.Ctx) string {
bearToken := c.Get("Authorization")

// Normally Authorization HTTP header.
onlyToken := strings.Split(bearToken, " ")
if len(onlyToken) == 2 {
return onlyToken[1]
}

return ""
}

func verifyToken(c *fiber.Ctx) (*jwt.Token, error) {
tokenString := extractToken(c)

token, err := jwt.Parse(tokenString, jwtKeyFunc)
if err != nil {
return nil, err
}

return token, nil
}

func jwtKeyFunc(token *jwt.Token) (interface{}, error) {
return []byte(os.Getenv("JWT_SECRET_KEY")), nil
}

测试应用程序

那么,我们已经到了最重要的阶段了!让我们通过测试来检查我们的 Fiber 应用程序。我会通过测试受JWT保护的私有路由来为您展示其工作原理。

☝️ 一如既往,我将利用Fiber的内置Test()方法以及出色的stretchr/testify包来测试Golang应用程序。

此外,我倾向于将测试配置保存在单独的文件中,以避免将生产配置与测试配置混杂在一起。因此,我使用名为.env.test的文件,并将其添加到项目的根目录中。

注意代码中定义路由的部分。我们正在调用应用程序的真实路由,因此在运行测试之前,您需要启动数据库(例如,为了简单起见,在 Docker 容器中)。

// ./pkg/routes/private_routes_test.go

package routes

import (
"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"
)

func TestPrivateRoutes(t *testing.T) {
// Load .env.test file from the root folder.
if err := 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()
if err != nil {
panic(err)
}

// Define a structure for specifying input and output data of a single test case.
tests := []struct {
description string
route string // input route
method string // input method
tokenString string // input token
body io.Reader
expectedError bool
expectedCode int
}{
{
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 cases
for _, test := range tests {
// 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 expected
assert.Equalf(t, test.expectedError, err != nil, test.description)

// As expected errors lead to broken responses,
// the next test case needs to be processed.
if test.expectedError {
continue
}

// Verify, if the status code is as expected.
assert.Equalf(t, test.expectedCode, resp.StatusCode, test.description)
}
}

// ...

本地运行项目

让我们运行 Docker 容器、应用迁移并转到http://127.0.0.1:5000/swagger/index.html

自查知识块

好的,我会尽量不参考教程文字,快速且诚实地回答。如果遗忘了某些内容,也希望不要影响到我们的交流。那么,我们可以开始了!

  • 允许应用程序在隔离环境中运行的技术名称是什么?
  • 应用程序的业务逻辑应该位于哪里(文件夹名称)?
  • 应该在项目的根目录中创建什么文件来描述为应用程序创建容器的过程?
  • 什么是 UUID?为什么我们用它作为 ID?
  • 我们使用什么类型的 PostgreSQL 字段来创建书籍属性模型?
  • 为什么在 Go 应用程序中使用纯 SQL 更好?
  • 您需要在哪里描述自动生成文档的API方法(通过Swagger)?
  • 为什么要在测试中单独配置?

进一步发展计划

为了进一步(独立)开发此应用程序,我建议考虑以下选项:

  1. 升级CreateBook方法:我们需要添加一个处理程序,用于将图片上传到云存储服务(例如Amazon S3或其他类似服务),并且只将图片ID保存在我们的数据库中。
  2. 升级GetBook方法GetBooks:添加一个处理程序,将云服务中的图片ID更改为直接链接到该图片;
  3. 添加用于注册新用户的新方法(例如,注册用户可以获得角色,这将允许他们执行 REST API 中的某些方法);
  4. 添加新的用户授权方法(例如,授权后,用户根据其角色收到包含凭据的 JWT 令牌);
  5. 为了存储授权用户的会话信息,我们需要添加一个独立的Redis(或其他类似的)容器。

原文链接:https://dev.to/koddr/build-a-restful-api-on-go-fiber-postgresql-jwt-and-swagger-docs-in-isolated-docker-containers-475j

#你可能也喜欢这些API文章!