Skip to content

Commit

Permalink
feat: add GetShortenInfo api
Browse files Browse the repository at this point in the history
  • Loading branch information
beihai0xff committed Jul 8, 2024
1 parent 3db9ca1 commit 317efc1
Show file tree
Hide file tree
Showing 12 changed files with 173 additions and 45 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ make deploy

```shell
curl -X POST http://localhost:8080/api/shorten -H 'Content-Type: application/json' -d '{"long_url": "https://google.com"}'
```
返回结果:
```json
{"short_url":"http://localhost/24rgcX","long_url":"https://google.com","error":""}
```

Expand Down
43 changes: 33 additions & 10 deletions app/turl/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"

"github.com/beihai0xff/turl/app/turl/model"
"github.com/beihai0xff/turl/configs"
"github.com/beihai0xff/turl/pkg/mapping"
)
Expand All @@ -33,49 +34,71 @@ func NewHandler(c *configs.ServerConfig) (*Handler, error) {

// Create creates a new short URL from the long URL.
func (h *Handler) Create(c *gin.Context) {
var req ShortenRequest
var req model.ShortenRequest

if err := c.ShouldBind(&req); err != nil {
c.JSON(http.StatusBadRequest, &ShortenResponse{LongURL: req.LongURL,
Error: err.Error()})
c.JSON(http.StatusBadRequest, &model.ShortenResponse{TinyURL: model.TinyURL{LongURL: req.LongURL}, Error: err.Error()})
return
}

short, err := h.s.Create(c, []byte(req.LongURL))
record, err := h.s.Create(c, []byte(req.LongURL))
if err != nil {
c.JSON(http.StatusInternalServerError, &ShortenResponse{Error: err.Error()})
c.JSON(http.StatusInternalServerError, &model.ShortenResponse{TinyURL: model.TinyURL{LongURL: req.LongURL}, Error: err.Error()})
return
}

c.JSON(http.StatusOK, &ShortenResponse{ShortURL: fmt.Sprintf("%s/%s", h.domain, short), LongURL: req.LongURL})
record.ShortURL = fmt.Sprintf("%s/%s", h.domain, record.ShortURL)

c.JSON(http.StatusOK, &model.ShortenResponse{TinyURL: *record})
}

// Redirect redirects the short URL to the original long URL temporarily if the short URL exists.
func (h *Handler) Redirect(c *gin.Context) {
short := []byte(c.Param("short"))
if len(short) > 8 || len(short) < 6 {
c.JSON(http.StatusBadRequest, &ShortenResponse{ShortURL: string(short), Error: "invalid short URL"})
c.JSON(http.StatusBadRequest, &model.ShortenResponse{TinyURL: model.TinyURL{ShortURL: string(short)}, Error: "invalid short URL"})
return
}

long, err := h.s.Retrieve(c, short)
if err != nil {
t := model.TinyURL{ShortURL: string(short)}
if errors.Is(err, mapping.ErrInvalidInput) {
c.JSON(http.StatusBadRequest, &ShortenResponse{ShortURL: string(short), Error: "invalid short URL"})
c.JSON(http.StatusBadRequest, &model.ShortenResponse{TinyURL: t, Error: "invalid short URL"})
return
}

if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, &ShortenResponse{ShortURL: string(short), Error: "short URL not found"})
c.JSON(http.StatusNotFound, &model.ShortenResponse{TinyURL: t, Error: "short URL not found"})
return
}

c.JSON(http.StatusInternalServerError, &ShortenResponse{ShortURL: string(short), Error: err.Error()})
c.JSON(http.StatusInternalServerError, &model.ShortenResponse{TinyURL: t, Error: err.Error()})
}

c.Redirect(http.StatusFound, string(long))
}

// GetShortenInfo returns the original long URL of the short URL.
func (h *Handler) GetShortenInfo(c *gin.Context) {
var req model.ShortenRequest

if err := c.ShouldBind(&req); err != nil {
c.JSON(http.StatusBadRequest, &model.ShortenResponse{TinyURL: model.TinyURL{LongURL: req.LongURL}, Error: err.Error()})
return
}

record, err := h.s.GetByLong(c, []byte(req.LongURL))
if err != nil {
c.JSON(http.StatusInternalServerError, &model.ShortenResponse{Error: err.Error()})
return
}

record.ShortURL = fmt.Sprintf("%s/%s", h.domain, record.ShortURL)

c.JSON(http.StatusOK, &model.ShortenResponse{TinyURL: *record})
}

// Close closes the handler.
func (h *Handler) Close() error {
return h.s.Close()
Expand Down
11 changes: 9 additions & 2 deletions app/turl/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/gin-gonic/gin"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"gorm.io/gorm"

"github.com/beihai0xff/turl/app/turl/model"
"github.com/beihai0xff/turl/internal/tests/mocks"
"github.com/beihai0xff/turl/pkg/mapping"
)
Expand All @@ -28,12 +30,17 @@ func TestHandler_Create(t *testing.T) {
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()

mockService.EXPECT().Create(mock.Anything, mock.Anything).Return([]byte("abc123"), nil).Times(1)
mockService.EXPECT().Create(mock.Anything, mock.Anything).Return(&model.TinyURL{
ShortURL: "abcefg",
LongURL: "https://www.example.com",
CreatedAt: time.Now(),
}, nil).Times(1)

router.ServeHTTP(resp, req)

require.Equal(t, http.StatusOK, resp.Code)
require.Equal(t, `{"short_url":"https://www.example.com/abc123","long_url":"https://www.example.com","error":""}`, resp.Body.String())
require.Contains(t, resp.Body.String(), `"short_url":"https://www.example.com/abcefg"`)
require.Contains(t, resp.Body.String(), `"long_url":"https://www.example.com"`)
})

t.Run("CreateInvalidURL", func(t *testing.T) {
Expand Down
1 change: 1 addition & 0 deletions app/turl/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ func NewServer(h *Handler, c *configs.ServerConfig) (*http.Server, error) {
api := router.Group("/api").Use(middleware.RateLimiter(
workqueue.NewItemRedisTokenRateLimiter[any](rdb, c.GlobalRateLimitKey, c.GlobalWriteRate, c.GlobalWriteBurst, time.Second)))
api.POST("/shorten", h.Create)
api.GET("/shorten", h.GetShortenInfo)
}

return &http.Server{
Expand Down
22 changes: 19 additions & 3 deletions app/turl/model.go → app/turl/model/model.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
package turl
// Package model implements the data model of the tiny URL service.
package model

import (
"time"

"gorm.io/gorm"
)

// ShortenRequest is the request of shorten API
type ShortenRequest struct {
Expand All @@ -8,10 +15,19 @@ type ShortenRequest struct {

// ShortenResponse is the response of shorten API
type ShortenResponse struct {
TinyURL
// Error is the error message if any error occurs
Error string `json:"error"`
}

// TinyURL is the tiny URL model, which is used to store the short URL and its original long URL
type TinyURL struct {
// ShortURL is the shortened URL
ShortURL string `json:"short_url"`
// LongURL is the original long URL
LongURL string `json:"long_url"`
// Error is the error message if any error occurs
Error string `json:"error"`
// CreatedAt is the creation time of the short URL
CreatedAt time.Time `json:"created_at"`
// DeletedAt is the deletion time of the short URL
DeletedAt gorm.DeletedAt `json:"deleted_at"`
}
10 changes: 6 additions & 4 deletions app/turl/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,30 @@ import (
"testing"

"github.com/stretchr/testify/require"

"github.com/beihai0xff/turl/app/turl/model"
)

func TestShortenRequest(t *testing.T) {
t.Run("ValidURL", func(t *testing.T) {
req := ShortenRequest{LongURL: "https://www.example.com"}
req := model.ShortenRequest{LongURL: "https://www.example.com"}
require.NotNil(t, req)
})

t.Run("InvalidURL", func(t *testing.T) {
req := ShortenRequest{LongURL: "invalid_url"}
req := model.ShortenRequest{LongURL: "invalid_url"}
require.NotNil(t, req)
})
}

func TestShortenResponse(t *testing.T) {
t.Run("ValidResponse", func(t *testing.T) {
resp := ShortenResponse{ShortURL: "https://turl.com/abc", LongURL: "https://www.example.com", Error: ""}
resp := model.ShortenResponse{TinyURL: model.TinyURL{ShortURL: "https://turl.com/abc", LongURL: "https://www.example.com"}, Error: ""}
require.NotNil(t, resp)
})

t.Run("ErrorResponse", func(t *testing.T) {
resp := ShortenResponse{Error: "An error occurred"}
resp := model.ShortenResponse{Error: "An error occurred"}
require.NotNil(t, resp)
})
}
37 changes: 32 additions & 5 deletions app/turl/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

"gorm.io/gorm"

"github.com/beihai0xff/turl/app/turl/model"
"github.com/beihai0xff/turl/configs"
"github.com/beihai0xff/turl/pkg/cache"
"github.com/beihai0xff/turl/pkg/db/mysql"
Expand All @@ -21,7 +22,8 @@ import (

// Service represents the tiny URL service interface.
type Service interface {
Create(ctx context.Context, long []byte) ([]byte, error)
Create(ctx context.Context, long []byte) (*model.TinyURL, error)
GetByLong(ctx context.Context, long []byte) (*model.TinyURL, error)
Retrieve(ctx context.Context, short []byte) ([]byte, error)
Close() error
}
Expand Down Expand Up @@ -128,7 +130,7 @@ type commandService struct {
}

// Create creates a new tiny URL.
func (c *commandService) Create(ctx context.Context, long []byte) ([]byte, error) {
func (c *commandService) Create(ctx context.Context, long []byte) (*model.TinyURL, error) {
if err := validate.Instance().VarCtx(ctx, string(long), "required,http_url"); err != nil {
return nil, err
}
Expand All @@ -138,12 +140,13 @@ func (c *commandService) Create(ctx context.Context, long []byte) ([]byte, error
return nil, fmt.Errorf("failed to generate sequence: %w", err)
}

if err = c.db.Insert(ctx, seq, long); err != nil {
record, err := c.db.Insert(ctx, seq, long)
if err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
slog.Error(fmt.Sprintf("failed to insert into db: %v, try to get from db", err),
slog.Any("long url", long), slog.Int64("seq", int64(seq)))

record, err := c.db.GetByLongURL(ctx, long)
record, err = c.db.GetByLongURL(ctx, long)
if err != nil {
return nil, fmt.Errorf("failed to get from db: %w", err)
}
Expand All @@ -160,7 +163,12 @@ func (c *commandService) Create(ctx context.Context, long []byte) ([]byte, error
slog.ErrorContext(ctx, "failed to set cache", slog.Any("error", err))
}

return short, nil
return &model.TinyURL{
ShortURL: string(short),
LongURL: string(long),
CreatedAt: record.CreatedAt,
DeletedAt: record.DeletedAt,
}, nil
}

// Close closes the command service.
Expand Down Expand Up @@ -221,6 +229,25 @@ func (q *queryService) Retrieve(ctx context.Context, short []byte) ([]byte, erro
return long, nil
}

// GetByLong returns the tiny URL by the long URL.
func (q *queryService) GetByLong(ctx context.Context, long []byte) (*model.TinyURL, error) {
if err := validate.Instance().VarCtx(ctx, string(long), "required,http_url"); err != nil {
return nil, err
}

record, err := q.db.GetByLongURL(ctx, long)
if err != nil {
return nil, err
}

return &model.TinyURL{
ShortURL: string(mapping.Base58Encode(record.Short)),
LongURL: string(record.LongURL),
CreatedAt: record.CreatedAt,
DeletedAt: record.DeletedAt,
}, nil
}

// Close closes the command service.
func (q *queryService) Close() error {
if err := q.db.Close(); err != nil {
Expand Down
50 changes: 41 additions & 9 deletions app/turl/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,9 @@ func TestService_Retrieve(t *testing.T) {
require.NoError(t, err)

t.Run("RetrieveExistingURL", func(t *testing.T) {
short, err := turl.Create(context.Background(), []byte("https://www.example.com"))
record, err := turl.Create(context.Background(), []byte("https://www.example.com"))
require.NoError(t, err)
got, err := turl.Retrieve(context.Background(), short)
got, err := turl.Retrieve(context.Background(), []byte(record.ShortURL))
require.NoError(t, err)
require.Equal(t, []byte("https://www.example.com"), got)
})
Expand Down Expand Up @@ -98,14 +98,21 @@ func TestService_Create_failed(t *testing.T) {

t.Run("CreateFailedToInsertIntoDB", func(t *testing.T) {
mockTDDL.EXPECT().Next(mock.Anything).Return(uint64(1), nil).Times(1)
mockStorage.EXPECT().Insert(mock.Anything, uint64(1), []byte("https://www.example.com")).Return(testErr).Times(1)
mockStorage.EXPECT().Insert(mock.Anything, uint64(1), []byte("https://www.example.com")).Return(nil, testErr).Times(1)
_, err := turl.Create(context.Background(), []byte("https://www.example.com"))
require.ErrorIs(t, err, testErr)
})

t.Run("CreateFailedToSetCache", func(t *testing.T) {
mockTDDL.EXPECT().Next(mock.Anything).Return(uint64(1), nil).Times(1)
mockStorage.EXPECT().Insert(mock.Anything, uint64(1), []byte("https://www.example.com")).Return(nil)
mockStorage.EXPECT().Insert(mock.Anything, uint64(1), []byte("https://www.example.com")).Return(&storage.TinyURL{
Short: 1e7,
LongURL: []byte("https://www.example.com"),
Model: gorm.Model{
ID: 10,
CreatedAt: time.Now(),
},
}, nil)
mockCache.EXPECT().Set(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(testErr).Times(1)
_, err := turl.Create(context.Background(), []byte("https://www.example.com"))
require.NoError(t, err)
Expand Down Expand Up @@ -157,20 +164,45 @@ func TestService_Retrieve_failed(t *testing.T) {
})
}

func Test_queryService_GetByLong(t *testing.T) {
s, err := newService(tests.GlobalConfig)
require.NoError(t, err)

t.Run("GetByLongSuccess", func(t *testing.T) {
record, err := s.Create(context.Background(), []byte("https://www.queryService_GetByLong.com"))
require.NoError(t, err)

got, err := s.GetByLong(context.Background(), []byte("https://www.queryService_GetByLong.com"))
require.NoError(t, err)
require.Equal(t, record.ShortURL, got.ShortURL)
})

t.Run("GetByLongNon-Existed", func(t *testing.T) {
got, err := s.GetByLong(context.Background(), nil)
require.Error(t, err)
got, err = s.GetByLong(context.Background(), []byte("example.com"))
require.Error(t, err)
got, err = s.GetByLong(context.Background(), []byte("https://www.Non-Existed.com"))
require.ErrorIs(t, err, gorm.ErrRecordNotFound)
require.Nil(t, got)
})
}

func Test_getDB(t *testing.T) {
c := *tests.GlobalConfig
t.Run("GetDBSuccess", func(t *testing.T) {
_, err := getDB(tests.GlobalConfig)
_, err := getDB(&c)
require.NoError(t, err)
})
t.Run("GetDBDebug", func(t *testing.T) {
tests.GlobalConfig.Debug = true
_, err := getDB(tests.GlobalConfig)
c.Debug = true
_, err := getDB(&c)
require.NoError(t, err)
})

t.Run("GetDBFailed", func(t *testing.T) {
tests.GlobalConfig.MySQL.DSN = "invalid_dsn"
_, err := getDB(tests.GlobalConfig)
c.MySQL.DSN = "invalid_dsn"
_, err := getDB(&c)
require.Error(t, err)
})
}
Loading

0 comments on commit 317efc1

Please sign in to comment.