19 сентября 2023 г.
Установка Go
Загружаем и устанавливаем Go с официального
сайта.
Создаём новое приложение:
mkdir go-api-app
cd go-api-app
Установка зависимостей
go mod init go-api-app
go get github.com/gin-gonic/gin
go get github.com/jinzhu/gorm
go get gorm.io/driver/postgres
go get github.com/dgrijalva/jwt-go
go get github.com/joho/godotenv
go get golang.org/x/crypto/bcrypt
go get golang.org/x/sync/semaphore
Файл .env
Создадим файл в корне:
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=password
DB_NAME=go_api_db
JWT_SECRET=your-super-secret-jwt-key-here
SERVER_PORT=3000
Конфигурация (config/config.go)
package config
import (
"os"
"strconv"
"github.com/joho/godotenv"
)
type Config struct {
DBHost string
DBPort int
DBUser string
DBPassword string
DBName string
JWTSecret string
ServerPort string
}
func LoadConfig() (*Config, error) {
err := godotenv.Load()
if err != nil {
return nil, err
}
dbPort, _ := strconv.Atoi(os.Getenv("DB_PORT"))
return &Config{
DBHost: os.Getenv("DB_HOST"),
DBPort: dbPort,
DBUser: os.Getenv("DB_USER"),
DBPassword: os.Getenv("DB_PASSWORD"),
DBName: os.Getenv("DB_NAME"),
JWTSecret: os.Getenv("JWT_SECRET"),
ServerPort: os.Getenv("SERVER_PORT"),
}, nil
}
Модели данных
Создадим и отредактируем файл (models/user.go):
package models
import (
"time"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type User struct {
ID uint `json:"id" gorm:"primaryKey"`
Username string `json:"username" gorm:"unique;not null"`
Email string `json:"email" gorm:"unique;not null"`
Password string `json:"-" gorm:"not null"`
Role string `json:"role" gorm:"default:'user'"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
func (u *User) HashPassword(password string) error {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
if err != nil {
return err
}
u.Password = string(bytes)
return nil
}
func (u *User) CheckPassword(password string) error {
return bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password))
}
Создадим и отредактируем файл (models/news.go):
package models
import (
"time"
"gorm.io/gorm"
)
type News struct {
ID uint `json:"id" gorm:"primaryKey"`
Title string `json:"title" gorm:"not null"`
Content string `json:"content" gorm:"type:text;not null"`
Author string `json:"author" gorm:"not null"`
Category string `json:"category"`
ImageURL string `json:"image_url"`
Published bool `json:"published" gorm:"default:false"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
type NewsResponse struct {
ID uint `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Author string `json:"author"`
Category string `json:"category"`
ImageURL string `json:"image_url"`
Published bool `json:"published"`
CreatedAt time.Time `json:"created_at"`
}
JWT утилиты (utils/jwt.go)
Создадим и отредактируем файл:
package utils
import (
"go-api-app/config"
"time"
"github.com/dgrijalva/jwt-go"
)
type Claims struct {
UserID uint `json:"user_id"`
Role string `json:"role"`
Username string `json:"username"`
jwt.StandardClaims
}
func GenerateJWT(userID uint, role string, username string) (string, error) {
cfg, err := config.LoadConfig()
if err != nil {
return "", err
}
expirationTime := time.Now().Add(24 * time.Hour)
claims := &Claims{
UserID: userID,
Role: role,
Username: username,
StandardClaims: jwt.StandardClaims{
ExpiresAt: expirationTime.Unix(),
IssuedAt: time.Now().Unix(),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(cfg.JWTSecret))
}
func VerifyJWT(tokenString string) (*Claims, error) {
cfg, err := config.LoadConfig()
if err != nil {
return nil, err
}
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
return []byte(cfg.JWTSecret), nil
})
if err != nil {
return nil, err
}
if !token.Valid {
return nil, jwt.ErrSignatureInvalid
}
return claims, nil
}
База данных (database/db.go)
Создадим и отредактируем файл:
package database
import (
"fmt"
"go-api-app/config"
"go-api-app/models"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
var DB *gorm.DB
func InitDB() error {
cfg, err := config.LoadConfig()
if err != nil {
return err
}
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
cfg.DBHost, cfg.DBPort, cfg.DBUser, cfg.DBPassword, cfg.DBName)
DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
return err
}
// Автомиграция
err = DB.AutoMigrate(&models.User{}, &models.News{})
if err != nil {
return err
}
fmt.Println("Database connected successfully")
return nil
}
Middleware аутентификации (middleware/auth.go)
Создадим и отредактируем файл:
package middleware
import (
"go-api-app/database"
"go-api-app/models"
"go-api-app/utils"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
c.Abort()
return
}
tokenString := strings.Replace(authHeader, "Bearer ", "", 1)
if tokenString == authHeader {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Bearer token required"})
c.Abort()
return
}
claims, err := utils.VerifyJWT(tokenString)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
c.Abort()
return
}
var user models.User
if err := database.DB.First(&user, claims.UserID).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
c.Abort()
return
}
c.Set("user_id", claims.UserID)
c.Set("user_role", claims.Role)
c.Set("username", user.Username)
c.Next()
}
}
func AdminMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
role, exists := c.Get("user_role")
if !exists || role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
c.Abort()
return
}
c.Next()
}
}
Обработчики
Создадим и отредактируем файл (handlers/auth.go):
package handlers
import (
"go-api-app/database"
"go-api-app/models"
"go-api-app/utils"
"net/http"
"github.com/gin-gonic/gin"
)
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
type RegisterRequest struct {
Username string `json:"username" binding:"required"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
}
func Register(c *gin.Context) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var existingUser models.User
if err := database.DB.Where("username = ? OR email = ?", req.Username, req.Email).First(&existingUser).Error; err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "Username or email already exists"})
return
}
user := models.User{
Username: req.Username,
Email: req.Email,
}
if err := user.HashPassword(req.Password); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not hash password"})
return
}
if err := database.DB.Create(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create user"})
return
}
token, err := utils.GenerateJWT(user.ID, user.Role, user.Username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not generate token"})
return
}
c.JSON(http.StatusCreated, gin.H{
"token": token,
"user": gin.H{
"id": user.ID,
"username": user.Username,
"email": user.Email,
"role": user.Role,
},
})
}
func Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var user models.User
if err := database.DB.Where("username = ?", req.Username).First(&user).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
if err := user.CheckPassword(req.Password); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
token, err := utils.GenerateJWT(user.ID, user.Role, user.Username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not generate token"})
return
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"user": gin.H{
"id": user.ID,
"username": user.Username,
"email": user.Email,
"role": user.Role,
},
})
}
Создадим и отредактируем файл (handlers/news.go):
package handlers
import (
"go-api-app/database"
"go-api-app/models"
"go-api-app/utils"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type CreateNewsRequest struct {
Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"`
Category string `json:"category"`
ImageURL string `json:"image_url"`
Published bool `json:"published"`
}
func GetNews(c *gin.Context) {
var news []models.News
query := database.DB
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
query = query.Where("published = ?", true)
} else {
tokenString := strings.Replace(authHeader, "Bearer ", "", 1)
if claims, err := utils.VerifyJWT(tokenString); err == nil {
var user models.User
if database.DB.First(&user, claims.UserID).Error == nil {
if user.Role == "admin" {
} else {
query = query.Where("published = ? OR author = ?", true, user.Username)
}
} else {
query = query.Where("published = ?", true)
}
} else {
query = query.Where("published = ?", true)
}
}
if category := c.Query("category"); category != "" {
query = query.Where("category = ?", category)
}
if err := query.Order("created_at desc").Find(&news).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not fetch news"})
return
}
c.JSON(http.StatusOK, news)
}
func GetNewsByID(c *gin.Context) {
id := c.Param("id")
var news models.News
if err := database.DB.First(&news, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "News not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not fetch news"})
return
}
if news.Published {
c.JSON(http.StatusOK, news)
return
}
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required to view unpublished news"})
return
}
tokenString := strings.Replace(authHeader, "Bearer ", "", 1)
if tokenString == authHeader {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Bearer token required"})
return
}
claims, err := utils.VerifyJWT(tokenString)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
return
}
var user models.User
if err := database.DB.First(&user, claims.UserID).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
return
}
if user.Role == "admin" {
c.JSON(http.StatusOK, news)
return
}
if user.Username == news.Author {
c.JSON(http.StatusOK, news)
return
}
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied. You can only view your own unpublished news"})
}
func CreateNews(c *gin.Context) {
var req CreateNewsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
usernameInterface, exists := c.Get("username")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "User information not found"})
return
}
username, ok := usernameInterface.(string)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid user information"})
return
}
news := models.News{
Title: req.Title,
Content: req.Content,
Author: username,
Category: req.Category,
ImageURL: req.ImageURL,
Published: req.Published,
}
if err := database.DB.Create(&news).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create news"})
return
}
c.JSON(http.StatusCreated, news)
}
func UpdateNews(c *gin.Context) {
id := c.Param("id")
var news models.News
if err := database.DB.First(&news, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "News not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not fetch news"})
return
}
userRole, _ := c.Get("user_role")
username, _ := c.Get("username")
if userRole != "admin" && username != news.Author {
c.JSON(http.StatusForbidden, gin.H{"error": "You can only edit your own news"})
return
}
var req map[string]interface{}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if title, exists := req["title"]; exists {
news.Title = title.(string)
}
if content, exists := req["content"]; exists {
news.Content = content.(string)
}
if category, exists := req["category"]; exists {
news.Category = category.(string)
}
if imageURL, exists := req["image_url"]; exists {
news.ImageURL = imageURL.(string)
}
if published, exists := req["published"]; exists {
news.Published = published.(bool)
}
if err := database.DB.Save(&news).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not update news"})
return
}
c.JSON(http.StatusOK, news)
}
func DeleteNews(c *gin.Context) {
id := c.Param("id")
var news models.News
if err := database.DB.First(&news, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "News! not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not fetch news"})
return
}
userRole, roleExists := c.Get("user_role")
username, usernameExists := c.Get("username")
if !roleExists || !usernameExists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "User information not available"})
return
}
if userRole != "admin" {
if username != news.Author {
c.JSON(http.StatusForbidden, gin.H{"error": "You can only delete your own news"})
return
}
}
if err := database.DB.Delete(&news).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not delete news"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "News deleted successfully"})
}
Основной файл (main.go)
Создадим финальный файл:
package main
import (
"go-api-app/config"
"go-api-app/database"
"go-api-app/handlers"
"go-api-app/middleware"
"log"
"github.com/gin-gonic/gin"
)
func main() {
cfg, err := config.LoadConfig()
if err != nil {
log.Fatal("Could not load config:", err)
}
if err := database.InitDB(); err != nil {
log.Fatal("Could not connect to database:", err)
}
r := gin.Default()
public := r.Group("/api")
{
public.POST("/register", handlers.Register)
public.POST("/login", handlers.Login)
public.GET("/news", handlers.GetNews)
public.GET("/news/:id", handlers.GetNewsByID)
}
protected := r.Group("/api")
protected.Use(middleware.AuthMiddleware())
{
protected.POST("/news", handlers.CreateNews)
protected.PUT("/news/:id", handlers.UpdateNews)
protected.DELETE("/news/:id", handlers.DeleteNews)
}
log.Printf("Server starting on port %s", cfg.ServerPort)
if err := r.Run(":" + cfg.ServerPort); err != nil {
log.Fatal("Could not start server:", err)
}
}
Использование API
Запускаем наш API:
go run main.go
Сервер запуститься по адресу http://127.0.0.1:3000.
Регистрация нового пользователя:
curl -X POST http://localhost:3000/api/register \
-H "Content-Type: application/json" \
-d '{"username":"user1","email":"user1@test.ru","password":"password123"}'
Логин:
curl -X POST http://localhost:3000/api/login \
-H "Content-Type: application/json" \
-d '{"username":"user1","password":"password123"}'
Получение списка всех новостей (видно все новости со статусом published=true):
curl http://localhost:3000/api/news
Получение списка всех новостей (видно все новости автора):
curl http://localhost:3000/api/news/2 \
-H "Authorization: Bearer $TOKEN"
С фильтрацией по категории:
curl "http://localhost:3000/api/news?category=технологии"
Получить новость по ID:
curl http://localhost:3000/api/news/1
С авторизацией (чтобы видеть неопубликованные):
curl http://localhost:3000/api/news/1 \
-H "Authorization: Bearer $TOKEN"
Создание новости:
curl -X POST http://localhost:3000/api/news \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-d '{"title":"Новая новость","content":"Текст новости","category":"политика","published":true}'
Обновление новости:
curl -X PUT http://localhost:3000/api/news/1 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"title": "Обновленная новость",
"content": "Это обновленное содержание новости",
"category": "наука",
"image_url": "https://www.jas.ru/images/updated-image.jpg",
"published": false
}'
Обновление только отдельных полей новости:
curl -X PUT http://localhost:3000/api/news/1 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"title": "Только заголовок изменен",
"published": true
}'
Удаление новости (с ID 1):
curl -X DELETE http://localhost:3000/api/news/1 \
-H "Authorization: Bearer $TOKEN"
Успешный ответ: {"message":"News deleted successfully"}
Сборка приложения
go build main.go
Готово!
Разработанное приложение представляет собой полнофункциональный REST API сервис для управления новостями с системой аутентификации и авторизации. API предоставляет гибкую систему прав доступа: анонимные пользователи могут просматривать только опубликованные новости, авторизованные пользователи получают доступ к своим неопубликованным материалам, а администраторы имеют полный контроль над всем контентом.
Приложение реализовано на современном стеке технологий Go с использованием фреймворка Gin, ORM Gorm для работы с базой данных и JWT токенов для безопасной аутентификации. API поддерживает все CRUD операции, фильтрацию по категориям и обеспечивает надежную защиту данных через проверку прав доступа на уровне каждого endpoint.