Skip to content

Commit

Permalink
feat: initialize articles api
Browse files Browse the repository at this point in the history
  • Loading branch information
samdyra committed Aug 4, 2024
1 parent 75aa292 commit 81ee5f7
Show file tree
Hide file tree
Showing 10 changed files with 315 additions and 15 deletions.
28 changes: 15 additions & 13 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import (

"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"golang.org/x/time/rate"

"github.com/samdyra/go-geo/internal/api"
"github.com/samdyra/go-geo/internal/config"
"github.com/samdyra/go-geo/internal/database"
Expand All @@ -19,7 +17,9 @@ func main() {
cfg := config.Load()
db := database.NewDB(cfg)
authService := services.NewAuthService(db)
handler := api.NewHandler(authService)
articleService := services.NewArticleService(db)
authHandler := api.NewHandler(authService)
articleHandler := api.NewArticleHandler(articleService)

r := gin.Default()

Expand All @@ -33,20 +33,22 @@ func main() {
MaxAge: 12 * time.Hour,
}))

// Rate limiting middleware
limiter := middleware.NewIPRateLimiter(rate.Limit(1), 5) // 1 request per second with burst of 5
r.Use(middleware.RateLimitMiddleware(limiter))
// Auth routes
r.POST("/signup", authHandler.SignUp)
r.POST("/signin", authHandler.SignIn)
r.POST("/logout", authHandler.Logout)

// Public routes
r.POST("/signup", handler.SignUp)
r.POST("/signin", handler.SignIn)
r.POST("/logout", handler.Logout)
// Article routes
r.GET("/articles", articleHandler.GetArticles)
r.GET("/articles/:id", articleHandler.GetArticle)

// Protected routes
protected := r.Group("/api")
// Protected article routes
protected := r.Group("/articles")
protected.Use(middleware.JWTAuth())
{
protected.GET("/protected", handler.ProtectedRoute)
protected.POST("", articleHandler.CreateArticle)
protected.PUT("/:id", articleHandler.UpdateArticle)
protected.DELETE("/:id", articleHandler.DeleteArticle)
}

log.Printf("Starting server on :%s", cfg.ServerPort)
Expand Down
Binary file modified go-geo
Binary file not shown.
121 changes: 121 additions & 0 deletions internal/api/article_handlers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package api

import (
"net/http"
"strconv"

"github.com/gin-gonic/gin"
"github.com/samdyra/go-geo/internal/models"
"github.com/samdyra/go-geo/internal/services"
"github.com/samdyra/go-geo/internal/utils/errors"
)

type ArticleHandler struct {
articleService *services.ArticleService
}

func NewArticleHandler(articleService *services.ArticleService) *ArticleHandler {
return &ArticleHandler{articleService: articleService}
}

func (h *ArticleHandler) GetArticles(c *gin.Context) {
articles, err := h.articleService.GetArticles()
if err != nil {
c.JSON(http.StatusInternalServerError, errors.NewAPIError(err))
return
}
c.JSON(http.StatusOK, articles)
}

func (h *ArticleHandler) GetArticle(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, errors.NewAPIError(errors.ErrInvalidInput))
return
}

article, err := h.articleService.GetArticleByID(id)
if err != nil {
c.JSON(http.StatusNotFound, errors.NewAPIError(err))
return
}
c.JSON(http.StatusOK, article)
}

func (h *ArticleHandler) CreateArticle(c *gin.Context) {
var input models.CreateArticleInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, errors.NewAPIError(errors.ErrInvalidInput))
return
}

if err := input.Validate(); err != nil {
c.JSON(http.StatusBadRequest, errors.NewAPIError(err))
return
}

userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, errors.NewAPIError(errors.ErrUnauthorized))
return
}

userIDInt, ok := userID.(int64)
if !ok {
c.JSON(http.StatusInternalServerError, errors.NewAPIError(errors.ErrInternalServer))
return
}

article, err := h.articleService.CreateArticle(input, userIDInt)
if err != nil {
c.JSON(http.StatusInternalServerError, errors.NewAPIError(err))
return
}

c.JSON(http.StatusCreated, article)
}

func (h *ArticleHandler) UpdateArticle(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, errors.NewAPIError(errors.ErrInvalidInput))
return
}

var input models.UpdateArticleInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, errors.NewAPIError(errors.ErrInvalidInput))
return
}

if err := input.Validate(); err != nil {
c.JSON(http.StatusBadRequest, errors.NewAPIError(err))
return
}

userID, _ := c.Get("user_id")
article, err := h.articleService.UpdateArticle(id, input, userID.(int64))
if err != nil {
c.JSON(http.StatusInternalServerError, errors.NewAPIError(err))
return
}

c.JSON(http.StatusOK, article)
}

func (h *ArticleHandler) DeleteArticle(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, errors.NewAPIError(errors.ErrInvalidInput))
return
}

userID, _ := c.Get("user_id")
err = h.articleService.DeleteArticle(id, userID.(int64))
if err != nil {
c.JSON(http.StatusInternalServerError, errors.NewAPIError(err))
return
}

c.JSON(http.StatusOK, gin.H{"message": "Article deleted successfully"})
}
2 changes: 1 addition & 1 deletion internal/api/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import (
"net/http"

"github.com/gin-gonic/gin"
"github.com/samdyra/go-geo/internal/auth"
"github.com/samdyra/go-geo/internal/models"
"github.com/samdyra/go-geo/internal/services"
"github.com/samdyra/go-geo/internal/utils/auth"
"github.com/samdyra/go-geo/internal/utils/errors"
)

Expand Down
3 changes: 2 additions & 1 deletion internal/middleware/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"github.com/gin-gonic/gin"
"github.com/samdyra/go-geo/internal/utils/auth"
"github.com/samdyra/go-geo/internal/utils/errors"
)

func JWTAuth() gin.HandlerFunc {
Expand All @@ -26,7 +27,7 @@ func JWTAuth() gin.HandlerFunc {

userID, err := auth.ValidateToken(parts[1])
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
c.JSON(http.StatusUnauthorized, errors.NewAPIError(errors.ErrUnauthorized))
c.Abort()
return
}
Expand Down
46 changes: 46 additions & 0 deletions internal/models/article.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package models

import (
"time"

validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
)

type Article struct {
ID int64 `db:"id" json:"id"`
Title string `db:"title" json:"title"`
Author string `db:"author" json:"author"`
Content string `db:"content" json:"content"`
ImageURL *string `db:"image_url" json:"image_url,omitempty"`
CreatedBy int64 `db:"created_by" json:"created_by"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}

type CreateArticleInput struct {
Title string `json:"title"`
Content string `json:"content"`
ImageURL *string `json:"image_url,omitempty"`
}

type UpdateArticleInput struct {
Title *string `json:"title,omitempty"`
Content *string `json:"content,omitempty"`
ImageURL *string `json:"image_url,omitempty"`
}

func (i CreateArticleInput) Validate() error {
return validation.ValidateStruct(&i,
validation.Field(&i.Title, validation.Required, validation.Length(1, 255)),
validation.Field(&i.Content, validation.Required),
validation.Field(&i.ImageURL, validation.NilOrNotEmpty, is.URL),
)
}

func (i UpdateArticleInput) Validate() error {
return validation.ValidateStruct(&i,
validation.Field(&i.Title, validation.NilOrNotEmpty, validation.Length(1, 255)),
validation.Field(&i.Content, validation.NilOrNotEmpty),
validation.Field(&i.ImageURL, validation.NilOrNotEmpty, is.URL),
)
}
116 changes: 116 additions & 0 deletions internal/services/article_service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package services

import (
"database/sql"
"log"
"time"

"github.com/jmoiron/sqlx"
"github.com/samdyra/go-geo/internal/models"
"github.com/samdyra/go-geo/internal/utils/errors"
)

type ArticleService struct {
db *sqlx.DB
}

func NewArticleService(db *sqlx.DB) *ArticleService {
return &ArticleService{db: db}
}

func (s *ArticleService) GetArticles() ([]models.Article, error) {
var articles []models.Article
err := s.db.Select(&articles, "SELECT * FROM articles ORDER BY created_at DESC")
if err != nil {
return nil, errors.ErrInternalServer
}
return articles, nil
}

func (s *ArticleService) GetArticleByID(id int64) (*models.Article, error) {
var article models.Article
err := s.db.Get(&article, "SELECT * FROM articles WHERE id = $1", id)
if err == sql.ErrNoRows {
return nil, errors.ErrNotFound
}
if err != nil {
return nil, errors.ErrInternalServer
}
return &article, nil
}

func (s *ArticleService) CreateArticle(input models.CreateArticleInput, userID int64) (*models.Article, error) {
var username string
err := s.db.Get(&username, "SELECT username FROM users WHERE id = $1", userID)
if err != nil {
log.Printf("Error fetching username: %v", err)
return nil, errors.ErrInternalServer
}

article := &models.Article{
Title: input.Title,
Content: input.Content,
ImageURL: input.ImageURL,
Author: username,
CreatedBy: userID,
CreatedAt: time.Now(),
}

query := `INSERT INTO articles (title, content, image_url, author, created_by, created_at)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`
err = s.db.QueryRow(query, article.Title, article.Content, article.ImageURL, article.Author, article.CreatedBy, article.CreatedAt).
Scan(&article.ID)
if err != nil {
log.Printf("Error creating article: %v", err)
return nil, errors.ErrInternalServer
}

return article, nil
}

func (s *ArticleService) UpdateArticle(id int64, input models.UpdateArticleInput, userID int64) (*models.Article, error) {
article, err := s.GetArticleByID(id)
if err != nil {
return nil, err
}

if article.CreatedBy != userID {
return nil, errors.ErrUnauthorized
}

if input.Title != nil {
article.Title = *input.Title
}
if input.Content != nil {
article.Content = *input.Content
}
if input.ImageURL != nil {
article.ImageURL = input.ImageURL
}

query := `UPDATE articles SET title = $1, content = $2, image_url = $3 WHERE id = $4`
_, err = s.db.Exec(query, article.Title, article.Content, article.ImageURL, id)
if err != nil {
return nil, errors.ErrInternalServer
}

return article, nil
}

func (s *ArticleService) DeleteArticle(id int64, userID int64) error {
article, err := s.GetArticleByID(id)
if err != nil {
return err
}

if article.CreatedBy != userID {
return errors.ErrUnauthorized
}

_, err = s.db.Exec("DELETE FROM articles WHERE id = $1", id)
if err != nil {
return errors.ErrInternalServer
}

return nil
}
3 changes: 3 additions & 0 deletions internal/utils/errors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrInternalServer = errors.New("internal server error")
ErrUnauthorized = errors.New("unauthorized")
ErrNotFound = errors.New("resource not found") // Add this line
)

type APIError struct {
Expand All @@ -28,6 +29,8 @@ func NewAPIError(err error) APIError {
return APIError{Type: "INVALID_CREDENTIALS", Message: err.Error()}
case ErrUnauthorized:
return APIError{Type: "UNAUTHORIZED", Message: err.Error()}
case ErrNotFound:
return APIError{Type: "NOT_FOUND", Message: err.Error()} // Add this case
default:
return APIError{Type: "INTERNAL_SERVER_ERROR", Message: "An unexpected error occurred"}
}
Expand Down
1 change: 1 addition & 0 deletions migrations/002_create_articles_table.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE IF EXISTS articles;
Loading

0 comments on commit 81ee5f7

Please sign in to comment.