# 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網(wǎng)絡(luò)

為您的操作系統(tǒng)安裝并運(yùn)行Docker服務(wù)。順便提一下,在本教程中我使用的是最新版本(截至當(dāng)前)v20.10.2。

好的,讓我們創(chuàng)建一個(gè)新的 Docker 網(wǎng)絡(luò),名為dev-network

docker network create -d bridge dev-network

將來,當(dāng)我們在獨(dú)立的容器中分別運(yùn)行數(shù)據(jù)庫和Fiber實(shí)例時(shí),會用到這個(gè)配置。如果不這樣做,兩個(gè)容器將無法相互通信。

PostgreSQL 和初始遷移

那么,讓我們用數(shù)據(jù)庫啟動容器:

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

太棒了!現(xiàn)在我們準(zhǔn)備將原始結(jié)構(gòu)遷移到新的架構(gòu)中。這是用于遷移的文件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工具,通過一個(gè)控制臺命令輕松上下數(shù)據(jù)庫遷移。

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

Fiber 應(yīng)用程序的 Dockerfile

在項(xiàng)目根文件夾中創(chuàng)建一個(gè)?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"]

您說得對,您正在采用兩階段容器構(gòu)建方式,并使用 Golang 1.16.x 版本。在構(gòu)建應(yīng)用程序時(shí),通過設(shè)置?CGO_ENABLED=0?和?-ldflags="-s -w"?參數(shù)來減小最終生成的二進(jìn)制文件的大小。否則,這對于任何 Go 項(xiàng)目來說都是最常見的Dockerfile,您可以在任何地方使用。

構(gòu)建 Fiber Docker 鏡像的命令:

docker build -t fiber .

?? 不要忘記將.dockerignore文件添加到項(xiàng)目的根文件夾中,其中包含所有文件和文件夾,創(chuàng)建容器時(shí)應(yīng)忽略該文件。

從鏡像創(chuàng)建并啟動容器的命令:

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

工具

就像您從標(biāo)題中可能猜測到的那樣,我們不會太過關(guān)注記錄API方法的事情。僅僅因?yàn)橛邢馭wagger這樣出色的工具可以為我們完成所有工作!

實(shí)用部分

好了,我們已經(jīng)準(zhǔn)備好了所有必要的配置文件和工作環(huán)境,我們知道我們要?jiǎng)?chuàng)建什么。現(xiàn)在是時(shí)候打開我們最喜歡的 IDE 并開始編寫代碼了。

?? 請注意,我將在代碼的注釋中直接解釋一些關(guān)鍵點(diǎn),而不是在文章正文中進(jìn)行說明。

創(chuàng)建模型

在實(shí)現(xiàn)模型之前,我總是創(chuàng)建一個(gè)具有 SQL 結(jié)構(gòu)的遷移文件(來自第 3 章)。這使得一次呈現(xiàn)所有必要的模型字段變得更加容易。

// ./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,因?yàn)檫@是一種更為通用的做法,可以有效地增強(qiáng)您的應(yīng)用程序?qū)τ诔R姅?shù)字暴力攻擊(如猜測ID)的防護(hù)能力。

但這還不是全部。您需要編寫兩個(gè)特殊方法:

  1. Value(),用于返回結(jié)構(gòu)體的 JSON 編碼表示形式;
  2. Scan(),用于將 JSON 編碼值解碼到結(jié)構(gòu)字段中;

它們可能看起來像這樣:

// ...

// 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)
}

為模型字段創(chuàng)建驗(yàn)證器

好的,讓我們定義在將輸入傳遞給控制器??業(yè)務(wù)邏輯之前需要檢查的字段:

這些字段是我們最關(guān)心的,因?yàn)樵谀承﹫鼍跋滤鼈儠挠脩裟抢飦淼轿覀冞@里。順便說一句,這就是為什么我們不僅驗(yàn)證輸入數(shù)據(jù)的有效性,而且還將它們視為必需項(xiàng)(required)的原因。

這就是我實(shí)現(xiàn)驗(yàn)證器的方式:

// ./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來發(fā)布此功能。

創(chuàng)建查詢和控制器

數(shù)據(jù)庫查詢

為了不損失性能,我傾向于使用不含額外抽象層、類似于原生SQL查詢的方式,避免使用那些會增加復(fù)雜性和潛在性能開銷的ORM(對象關(guān)系映射)框架或類似的包。它能夠讓我們更深入地理解應(yīng)用程序的工作原理,這將有助于我們在將來優(yōu)化數(shù)據(jù)庫查詢時(shí)避免犯下愚蠢的錯(cuò)誤!

// ./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 }

創(chuàng)建模型控制器

方法原理:

// ./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,
})
}

// ...

方法原理:

// ...

// 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,
})
}

// ...

方法原理:

// ...

// 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)
}

// ...

方法原理:

// ...

// 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) 的方法

// ./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,
})
}

主要功能

這是我們整個(gè)應(yīng)用程序中的核心功能。它負(fù)責(zé)從?.env?文件中加載配置,設(shè)置 Swagger,創(chuàng)建 Fiber 實(shí)例,連接所需的端點(diǎn)組,并最終啟動 API 服務(wù)器。

// ./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)
}

中間件功能

由于在這個(gè)應(yīng)用程序中,我希望能展示如何利用JWT對某些查詢進(jìn)行授權(quán),因此我們需要編寫一個(gè)額外的中間件來進(jìn)行驗(yàn)證。

// ./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 端點(diǎn)的路由

// ./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
}
// ./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
}
// ./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
}
// ./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",
})
},
)
}

數(shù)據(jù)庫連接

數(shù)據(jù)庫連接無疑是該應(yīng)用程序中最為關(guān)鍵的一環(huán)(事實(shí)上,對于任何應(yīng)用程序而言都是如此)。我傾向于將這個(gè)連接過程拆分為兩個(gè)步驟。

// ./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
}

?? 這種方法有助于在需要時(shí)更輕松地連接其他數(shù)據(jù)庫,并始終在應(yīng)用程序中保持清晰的數(shù)據(jù)存儲層次結(jié)構(gòu)。

高效的實(shí)用程序

// ./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)
}
}
// ./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
}
// ./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
}

測試應(yīng)用程序

那么,我們已經(jīng)到了最重要的階段了!讓我們通過測試來檢查我們的 Fiber 應(yīng)用程序。我會通過測試受JWT保護(hù)的私有路由來為您展示其工作原理。

?? 一如既往,我將利用Fiber的內(nèi)置Test()方法以及出色的stretchr/testify包來測試Golang應(yīng)用程序。

此外,我傾向于將測試配置保存在單獨(dú)的文件中,以避免將生產(chǎn)配置與測試配置混雜在一起。因此,我使用名為.env.test的文件,并將其添加到項(xiàng)目的根目錄中。

注意代碼中定義路由的部分。我們正在調(diào)用應(yīng)用程序的真實(shí)路由,因此在運(yùn)行測試之前,您需要啟動數(shù)據(jù)庫(例如,為了簡單起見,在 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) } } // ...

本地運(yùn)行項(xiàng)目

讓我們運(yùn)行 Docker 容器、應(yīng)用遷移并轉(zhuǎn)到http://127.0.0.1:5000/swagger/index.html

自查知識塊

好的,我會盡量不參考教程文字,快速且誠實(shí)地回答。如果遺忘了某些內(nèi)容,也希望不要影響到我們的交流。那么,我們可以開始了!

進(jìn)一步發(fā)展計(jì)劃

為了進(jìn)一步(獨(dú)立)開發(fā)此應(yīng)用程序,我建議考慮以下選項(xiàng):

  1. 升級CreateBook方法:我們需要添加一個(gè)處理程序,用于將圖片上傳到云存儲服務(wù)(例如Amazon S3或其他類似服務(wù)),并且只將圖片ID保存在我們的數(shù)據(jù)庫中。
  2. 升級GetBook方法GetBooks:添加一個(gè)處理程序,將云服務(wù)中的圖片ID更改為直接鏈接到該圖片;
  3. 添加用于注冊新用戶的新方法(例如,注冊用戶可以獲得角色,這將允許他們執(zhí)行 REST API 中的某些方法);
  4. 添加新的用戶授權(quán)方法(例如,授權(quán)后,用戶根據(jù)其角色收到包含憑據(jù)的 JWT 令牌);
  5. 為了存儲授權(quán)用戶的會話信息,我們需要添加一個(gè)獨(dú)立的Redis(或其他類似的)容器。

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

上一篇:

用于保護(hù) .NET API 的 4 種授權(quán)方法

下一篇:

使用Auth0構(gòu)建和保護(hù)一個(gè)簡單的Symfony API服務(wù)器
#你可能也喜歡這些API文章!

我們有何不同?

API服務(wù)商零注冊

多API并行試用

數(shù)據(jù)驅(qū)動選型,提升決策效率

查看全部API→
??

熱門場景實(shí)測,選對API

#AI文本生成大模型API

對比大模型API的內(nèi)容創(chuàng)意新穎性、情感共鳴力、商業(yè)轉(zhuǎn)化潛力

25個(gè)渠道
一鍵對比試用API 限時(shí)免費(fèi)

#AI深度推理大模型API

對比大模型API的邏輯推理準(zhǔn)確性、分析深度、可視化建議合理性

10個(gè)渠道
一鍵對比試用API 限時(shí)免費(fèi)