745 lines
17 KiB
Go
745 lines
17 KiB
Go
package apiserver
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
|
|
"git.maronato.dev/maronato/goshort/internal/errs"
|
|
"git.maronato.dev/maronato/goshort/internal/server"
|
|
authmiddleware "git.maronato.dev/maronato/goshort/internal/server/middleware/auth"
|
|
configservice "git.maronato.dev/maronato/goshort/internal/service/config"
|
|
shortservice "git.maronato.dev/maronato/goshort/internal/service/short"
|
|
shortlogservice "git.maronato.dev/maronato/goshort/internal/service/shortlog"
|
|
tokenservice "git.maronato.dev/maronato/goshort/internal/service/token"
|
|
userservice "git.maronato.dev/maronato/goshort/internal/service/user"
|
|
"git.maronato.dev/maronato/goshort/internal/storage/models"
|
|
"git.maronato.dev/maronato/goshort/internal/util/logging"
|
|
"git.maronato.dev/maronato/goshort/internal/util/tracing"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/render"
|
|
)
|
|
|
|
type APIHandler struct {
|
|
shorts *shortservice.ShortService
|
|
users *userservice.UserService
|
|
tokens *tokenservice.TokenService
|
|
shortLogs *shortlogservice.ShortLogService
|
|
config *configservice.ConfigService
|
|
}
|
|
|
|
func NewAPIHandler(
|
|
shorts *shortservice.ShortService,
|
|
users *userservice.UserService,
|
|
tokens *tokenservice.TokenService,
|
|
shortLogs *shortlogservice.ShortLogService,
|
|
config *configservice.ConfigService,
|
|
) *APIHandler {
|
|
return &APIHandler{
|
|
shorts: shorts,
|
|
users: users,
|
|
tokens: tokens,
|
|
shortLogs: shortLogs,
|
|
config: config,
|
|
}
|
|
}
|
|
|
|
func (h *APIHandler) Me(w http.ResponseWriter, r *http.Request) {
|
|
_, span := tracing.StartSpan(r.Context(), "api.Me")
|
|
defer span.End()
|
|
|
|
// Get user from context
|
|
user, ok := h.findUserOrRespond(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
span.AddEvent("found user")
|
|
|
|
// Respond with the user
|
|
render.Status(r, http.StatusOK)
|
|
render.JSON(w, r, user)
|
|
}
|
|
|
|
func (h *APIHandler) DeleteMe(w http.ResponseWriter, r *http.Request) {
|
|
ctx, span := tracing.StartSpan(r.Context(), "api.DeleteMe")
|
|
defer span.End()
|
|
|
|
// Get user from context
|
|
user, ok := h.findUserOrRespond(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
span.AddEvent("found user")
|
|
|
|
// Delete all user's sessions
|
|
err := authmiddleware.DeleteAllUserSessions(ctx, user)
|
|
if err != nil {
|
|
server.RenderServerError(w, r, err)
|
|
|
|
return
|
|
}
|
|
|
|
span.AddEvent("deleted all user sessions")
|
|
|
|
// Delete the user
|
|
err = h.users.DeleteUser(ctx, user)
|
|
if err != nil {
|
|
server.RenderServerError(w, r, err)
|
|
|
|
return
|
|
}
|
|
|
|
span.AddEvent("deleted user")
|
|
|
|
// Logout and return
|
|
authmiddleware.LogoutUser(ctx)
|
|
span.AddEvent("logged out user")
|
|
|
|
render.NoContent(w, r)
|
|
}
|
|
|
|
func (h *APIHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|
ctx, span := tracing.StartSpan(r.Context(), "api.Login")
|
|
defer span.End()
|
|
|
|
l := logging.FromCtx(ctx)
|
|
|
|
type loginForm struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
var form *loginForm
|
|
|
|
if err := render.DecodeJSON(r.Body, &form); err != nil {
|
|
server.RenderBadRequest(w, r, err)
|
|
|
|
return
|
|
}
|
|
|
|
// Get user from storage
|
|
user, err := h.users.AuthenticateUser(ctx, form.Username, form.Password)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, errs.ErrUserDoesNotExist) || errors.Is(err, errs.ErrFailedAuthentication):
|
|
// If the username or password are wrong, return invalid username/password
|
|
l.Debug("failed to authenticate user", "err", err)
|
|
|
|
server.RenderUnauthorized(w, r)
|
|
case errors.Is(err, errs.ErrInvalidUser):
|
|
// If the request was invalid, return bad request
|
|
server.RenderBadRequest(w, r, err)
|
|
case errors.Is(err, errs.ErrCredentialsLoginDisabled):
|
|
// If credentials login is disabled, return forbidden
|
|
server.RenderForbidden(w, r)
|
|
default:
|
|
// Else, server error
|
|
server.RenderServerError(w, r, err)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
span.AddEvent("authenticated user")
|
|
|
|
// Login user
|
|
authmiddleware.LoginUser(ctx, user, r)
|
|
span.AddEvent("logged in user")
|
|
|
|
// Render the response
|
|
render.Status(r, http.StatusOK)
|
|
render.JSON(w, r, user)
|
|
}
|
|
|
|
func (h *APIHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
|
ctx, span := tracing.StartSpan(r.Context(), "api.Logout")
|
|
defer span.End()
|
|
|
|
// Logout user
|
|
authmiddleware.LogoutUser(ctx)
|
|
span.AddEvent("logged out user")
|
|
|
|
// Render the response
|
|
render.Status(r, http.StatusOK)
|
|
render.JSON(w, r, nil)
|
|
}
|
|
|
|
func (h *APIHandler) Signup(w http.ResponseWriter, r *http.Request) {
|
|
ctx, span := tracing.StartSpan(r.Context(), "api.Signup")
|
|
defer span.End()
|
|
|
|
l := logging.FromCtx(ctx)
|
|
|
|
if h.config.GetPublicConfig().DisableCredentialsLogin {
|
|
l.Debug("credentials registration is disabled")
|
|
|
|
server.RenderForbidden(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
// Get the user from the json body
|
|
type signupForm struct {
|
|
models.User
|
|
Password string `json:"password,omitempty"`
|
|
}
|
|
|
|
var form *signupForm
|
|
if err := render.DecodeJSON(r.Body, &form); err != nil {
|
|
err = fmt.Errorf("failed to parse form: %w", err)
|
|
server.RenderBadRequest(w, r, err)
|
|
|
|
return
|
|
}
|
|
|
|
span.AddEvent("parsed form")
|
|
|
|
// Get user and pass from form
|
|
user := &form.User
|
|
pass := form.Password
|
|
|
|
// Hash the password into the user
|
|
if err := h.users.SetPassword(ctx, user, pass); err != nil {
|
|
if errors.Is(err, errs.ErrInvalidUsernameOrPassword) {
|
|
server.RenderBadRequest(w, r, err)
|
|
} else {
|
|
server.RenderServerError(w, r, err)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
span.AddEvent("hashed password")
|
|
|
|
// Create user
|
|
newUser, err := h.users.CreateUser(ctx, user)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, errs.ErrUserExists) || errors.Is(err, errs.ErrInvalidUser):
|
|
server.RenderBadRequest(w, r, err)
|
|
case errors.Is(err, errs.ErrRegistrationDisabled):
|
|
l.Debug("failed to create user", "err", err)
|
|
|
|
server.RenderForbidden(w, r)
|
|
default:
|
|
server.RenderServerError(w, r, err)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
span.AddEvent("created user")
|
|
|
|
// Render the response
|
|
render.Status(r, http.StatusCreated)
|
|
render.JSON(w, r, newUser)
|
|
}
|
|
|
|
func (h *APIHandler) CreateShort(w http.ResponseWriter, r *http.Request) {
|
|
ctx, span := tracing.StartSpan(r.Context(), "api.CreateShort")
|
|
defer span.End()
|
|
|
|
// Get the URL from the json body
|
|
var short *models.Short
|
|
|
|
if err := render.DecodeJSON(r.Body, &short); err != nil {
|
|
server.RenderBadRequest(w, r, err)
|
|
|
|
return
|
|
}
|
|
|
|
span.AddEvent("parsed form")
|
|
|
|
// Get user from context
|
|
user, ok := h.findUserOrRespond(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
span.AddEvent("found user")
|
|
|
|
// Set the user
|
|
short.UserID = &user.ID
|
|
|
|
// Shorten URL
|
|
newShort, err := h.shorts.Shorten(ctx, short)
|
|
if err != nil {
|
|
if errors.Is(err, errs.ErrShortExists) || errors.Is(err, errs.ErrInvalidShort) {
|
|
server.RenderBadRequest(w, r, err)
|
|
} else {
|
|
server.RenderServerError(w, r, err)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
span.AddEvent("created short")
|
|
|
|
shortResponse := newShortResponse(r, newShort)
|
|
|
|
span.AddEvent("created shorted response")
|
|
|
|
// Render the response
|
|
render.Status(r, http.StatusCreated)
|
|
render.JSON(w, r, shortResponse)
|
|
}
|
|
|
|
func (h *APIHandler) ListShorts(w http.ResponseWriter, r *http.Request) {
|
|
ctx, span := tracing.StartSpan(r.Context(), "api.ListShorts")
|
|
defer span.End()
|
|
|
|
// Get user from context
|
|
user, ok := h.findUserOrRespond(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
span.AddEvent("found user")
|
|
|
|
// Get shorts
|
|
shorts, err := h.shorts.ListShorts(ctx, user)
|
|
if err != nil {
|
|
server.RenderServerError(w, r, err)
|
|
|
|
return
|
|
}
|
|
|
|
span.AddEvent("listed shorts")
|
|
|
|
shortsResponse := newShortResponseList(r, shorts)
|
|
|
|
span.AddEvent("created shorts response")
|
|
|
|
// Render the response
|
|
render.Status(r, http.StatusOK)
|
|
render.JSON(w, r, shortsResponse)
|
|
}
|
|
|
|
func (h *APIHandler) FindShort(w http.ResponseWriter, r *http.Request) {
|
|
_, span := tracing.StartSpan(r.Context(), "api.FindShort")
|
|
defer span.End()
|
|
|
|
// Find own short or respond
|
|
short, ok := h.findShortOrRespond(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
span.AddEvent("found short")
|
|
|
|
shortResponse := newShortResponse(r, short)
|
|
|
|
span.AddEvent("created short response")
|
|
|
|
// Render the short
|
|
render.Status(r, http.StatusOK)
|
|
render.JSON(w, r, shortResponse)
|
|
}
|
|
|
|
func (h *APIHandler) DeleteShort(w http.ResponseWriter, r *http.Request) {
|
|
ctx, span := tracing.StartSpan(r.Context(), "api.DeleteShort")
|
|
defer span.End()
|
|
|
|
// Find own short or respond
|
|
short, ok := h.findShortOrRespond(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
span.AddEvent("found short")
|
|
|
|
// Delete short
|
|
err := h.shorts.DeleteShort(ctx, short)
|
|
if err != nil {
|
|
server.RenderServerError(w, r, err)
|
|
|
|
return
|
|
}
|
|
|
|
span.AddEvent("deleted short")
|
|
|
|
// Deleted, return no content
|
|
render.NoContent(w, r)
|
|
}
|
|
|
|
func (h *APIHandler) ListShortLogs(w http.ResponseWriter, r *http.Request) {
|
|
ctx, span := tracing.StartSpan(r.Context(), "api.ListShortLogs")
|
|
defer span.End()
|
|
|
|
// Find own short or respond
|
|
short, ok := h.findShortOrRespond(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
span.AddEvent("found short")
|
|
|
|
// Get logs
|
|
logs, err := h.shortLogs.ListLogs(ctx, short)
|
|
if err != nil {
|
|
server.RenderServerError(w, r, err)
|
|
|
|
return
|
|
}
|
|
|
|
span.AddEvent("listed logs")
|
|
|
|
// Render the response
|
|
render.Status(r, http.StatusOK)
|
|
render.JSON(w, r, logs)
|
|
}
|
|
|
|
func (h *APIHandler) ListSessions(w http.ResponseWriter, r *http.Request) {
|
|
ctx, span := tracing.StartSpan(r.Context(), "api.ListSessions")
|
|
|
|
// Get user from context
|
|
user, ok := h.findUserOrRespond(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
span.AddEvent("found user")
|
|
|
|
sessions, err := authmiddleware.ListUserSessions(ctx, user)
|
|
if err != nil {
|
|
server.RenderServerError(w, r, err)
|
|
}
|
|
|
|
span.AddEvent("listed sessions")
|
|
|
|
// Render the response
|
|
render.Status(r, http.StatusOK)
|
|
render.JSON(w, r, sessions)
|
|
}
|
|
|
|
func (h *APIHandler) DeleteSession(w http.ResponseWriter, r *http.Request) {
|
|
ctx, span := tracing.StartSpan(r.Context(), "api.DeleteSession")
|
|
defer span.End()
|
|
|
|
l := logging.FromCtx(ctx)
|
|
|
|
// Get user from context
|
|
user, ok := h.findUserOrRespond(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
span.AddEvent("found user")
|
|
|
|
// Get session token from request
|
|
sessionToken := chi.URLParam(r, "id")
|
|
|
|
// Delete session
|
|
err := authmiddleware.DeleteUserSession(ctx, user, sessionToken)
|
|
if err != nil {
|
|
if errors.Is(err, errs.ErrSessionDoesNotExist) {
|
|
l.Debug("could not delete session", "err", err)
|
|
|
|
server.RenderNotFound(w, r)
|
|
} else {
|
|
server.RenderServerError(w, r, err)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
span.AddEvent("deleted session")
|
|
|
|
// Render the response
|
|
render.NoContent(w, r)
|
|
}
|
|
|
|
// ListTokens lists all tokens belonging to the user.
|
|
func (h *APIHandler) ListTokens(w http.ResponseWriter, r *http.Request) {
|
|
ctx, span := tracing.StartSpan(r.Context(), "api.ListTokens")
|
|
defer span.End()
|
|
|
|
// Get user from context
|
|
user, ok := h.findUserOrRespond(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
span.AddEvent("found user")
|
|
|
|
// Get tokens
|
|
tokens, err := h.tokens.ListTokens(ctx, user)
|
|
if err != nil {
|
|
server.RenderServerError(w, r, err)
|
|
}
|
|
|
|
span.AddEvent("listed tokens")
|
|
|
|
// Render the response
|
|
render.Status(r, http.StatusOK)
|
|
render.JSON(w, r, tokens)
|
|
}
|
|
|
|
// CreateToken creates a new token for the user.
|
|
func (h *APIHandler) CreateToken(w http.ResponseWriter, r *http.Request) {
|
|
ctx, span := tracing.StartSpan(r.Context(), "api.CreateToken")
|
|
defer span.End()
|
|
|
|
type tokenNameForm struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
form := &tokenNameForm{}
|
|
|
|
// if error is EOF, it means the body is empty, so we can ignore it
|
|
if err := render.DecodeJSON(r.Body, form); err != nil && errors.Is(err, io.EOF) {
|
|
server.RenderBadRequest(w, r, err)
|
|
|
|
return
|
|
}
|
|
|
|
span.AddEvent("parsed form")
|
|
|
|
// Get user from context
|
|
user, ok := h.findUserOrRespond(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
span.AddEvent("found user")
|
|
|
|
token, err := h.tokens.CreateToken(ctx, user, form.Name)
|
|
if err != nil {
|
|
server.RenderServerError(w, r, err)
|
|
|
|
return
|
|
}
|
|
|
|
span.AddEvent("created token")
|
|
|
|
// Render the response
|
|
render.Status(r, http.StatusCreated)
|
|
render.JSON(w, r, token)
|
|
}
|
|
|
|
// ChangeTokenName changes a token's name belonging to the user.
|
|
func (h *APIHandler) ChangeTokenName(w http.ResponseWriter, r *http.Request) {
|
|
ctx, span := tracing.StartSpan(r.Context(), "api.ChangeTokenName")
|
|
defer span.End()
|
|
|
|
type tokenNameForm struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
var form *tokenNameForm
|
|
if err := render.DecodeJSON(r.Body, &form); err != nil {
|
|
server.RenderBadRequest(w, r, err)
|
|
|
|
return
|
|
}
|
|
|
|
span.AddEvent("parsed form")
|
|
|
|
// Find token or respond
|
|
token, ok := h.findTokenOrRespond(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
span.AddEvent("found token")
|
|
|
|
// Rename token
|
|
newToken, err := h.tokens.ChangeTokenName(ctx, token, form.Name)
|
|
if err != nil {
|
|
server.RenderServerError(w, r, err)
|
|
|
|
return
|
|
}
|
|
|
|
span.AddEvent("changed token name")
|
|
|
|
// Changed. Return the token
|
|
render.Status(r, http.StatusOK)
|
|
render.JSON(w, r, newToken)
|
|
}
|
|
|
|
// DeleteToken deletes a token belonging to the user.
|
|
func (h *APIHandler) DeleteToken(w http.ResponseWriter, r *http.Request) {
|
|
ctx, span := tracing.StartSpan(r.Context(), "api.DeleteToken")
|
|
defer span.End()
|
|
|
|
// Find token or respond
|
|
token, ok := h.findTokenOrRespond(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
span.AddEvent("found token")
|
|
|
|
// Delete token
|
|
err := h.tokens.DeleteToken(ctx, token)
|
|
if err != nil {
|
|
server.RenderServerError(w, r, err)
|
|
|
|
return
|
|
}
|
|
|
|
span.AddEvent("deleted token")
|
|
|
|
// Deleted, return no content
|
|
render.NoContent(w, r)
|
|
}
|
|
|
|
// PublicConfig returns the public configuration of the server.
|
|
func (h *APIHandler) PublicConfig(w http.ResponseWriter, r *http.Request) {
|
|
_, span := tracing.StartSpan(r.Context(), "api.PublicConfig")
|
|
defer span.End()
|
|
|
|
// Get public config
|
|
config := h.config.GetPublicConfig()
|
|
|
|
span.AddEvent("got public config")
|
|
|
|
// Render the response
|
|
render.Status(r, http.StatusOK)
|
|
render.JSON(w, r, config)
|
|
}
|
|
|
|
// findUserOrRespond is a helper function that finds a user in the session,
|
|
// and returns it. If the user is not found, it returns nil and false.
|
|
func (h *APIHandler) findUserOrRespond(w http.ResponseWriter, r *http.Request) (user *models.User, ok bool) {
|
|
ctx := r.Context()
|
|
|
|
// Get user from context
|
|
user, ok = authmiddleware.UserFromCtx(ctx)
|
|
if !ok {
|
|
server.RenderServerError(w, r, errs.ErrInvalidUser)
|
|
|
|
return nil, false
|
|
}
|
|
|
|
return user, true
|
|
}
|
|
|
|
// findShortOrRespond is a helper function that finds a short specified in the request params,
|
|
// and checks if the user in the session is the same as the short's user. If it is, it returns
|
|
// the short and true. If it isn't, it returns nil and false.
|
|
func (h *APIHandler) findShortOrRespond(w http.ResponseWriter, r *http.Request) (short *models.Short, ok bool) { //nolint:dupl // This and the below function are similar, but different enough to not be refactored.
|
|
ctx := r.Context()
|
|
l := logging.FromCtx(ctx)
|
|
|
|
// Get short id from request
|
|
id := chi.URLParam(r, "id")
|
|
|
|
// Find short in storage
|
|
short, err := h.shorts.FindShortByID(ctx, id)
|
|
if err != nil {
|
|
// If the short doesn't exist or is invalid, return not found
|
|
if errors.Is(err, errs.ErrShortDoesNotExist) || errors.Is(err, errs.ErrInvalidShort) {
|
|
l.Debug("could not find short", "err", err)
|
|
|
|
server.RenderNotFound(w, r)
|
|
} else {
|
|
server.RenderServerError(w, r, err)
|
|
}
|
|
|
|
return nil, false
|
|
}
|
|
|
|
// Get user from context
|
|
user, ok := h.findUserOrRespond(w, r)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
|
|
// If the session user does not match the short's user,
|
|
// return forbidden.
|
|
if user.ID != *short.UserID {
|
|
l.Debug("short's user does not match request user", slog.String("short_user", *short.UserID))
|
|
|
|
server.RenderForbidden(w, r)
|
|
|
|
return nil, false
|
|
}
|
|
|
|
return short, true
|
|
}
|
|
|
|
// findTokenOrRespond is a helper function that finds a token specified in the request params,
|
|
// and checks if the user in the session is the same as the tokens's user. If it is, it returns
|
|
// the token and true. If it isn't, it returns nil and false.
|
|
func (h *APIHandler) findTokenOrRespond(w http.ResponseWriter, r *http.Request) (token *models.Token, ok bool) { //nolint:dupl // This and the above function are similar, but different enough to not be refactored.
|
|
ctx := r.Context()
|
|
l := logging.FromCtx(ctx)
|
|
|
|
// Get token ID from request
|
|
id := chi.URLParam(r, "id")
|
|
|
|
// Find token in storage
|
|
token, err := h.tokens.FindTokenByID(ctx, id)
|
|
if err != nil {
|
|
// If the token doesn't exist or is invalid, return not found
|
|
if errors.Is(err, errs.ErrTokenDoesNotExist) || errors.Is(err, errs.ErrInvalidToken) {
|
|
l.Debug("could not find token", "err", err)
|
|
|
|
server.RenderNotFound(w, r)
|
|
} else {
|
|
server.RenderServerError(w, r, err)
|
|
}
|
|
|
|
return nil, false
|
|
}
|
|
|
|
// Get user from context
|
|
user, ok := h.findUserOrRespond(w, r)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
|
|
// If the session user does not match the token's user,
|
|
// return NotFound.
|
|
if user.ID != *token.UserID {
|
|
l.Debug("token's user does not match request user", slog.String("token_user", *token.UserID))
|
|
|
|
server.RenderNotFound(w, r)
|
|
|
|
return nil, false
|
|
}
|
|
|
|
return token, true
|
|
}
|
|
|
|
type shortResponse struct {
|
|
*models.Short
|
|
ShortURL string `json:"shorturl"`
|
|
}
|
|
|
|
func newShortResponse(r *http.Request, short *models.Short) *shortResponse {
|
|
// Add `shorturl` to response so it can be used by third parties
|
|
scheme := "http"
|
|
if r.TLS != nil {
|
|
scheme = "https"
|
|
}
|
|
|
|
shortURL := url.URL{
|
|
Scheme: scheme,
|
|
Host: r.Host,
|
|
Path: short.Name,
|
|
}
|
|
|
|
return &shortResponse{
|
|
Short: short,
|
|
ShortURL: shortURL.String(),
|
|
}
|
|
}
|
|
|
|
func newShortResponseList(r *http.Request, shorts []*models.Short) []*shortResponse {
|
|
shortResponses := make([]*shortResponse, len(shorts))
|
|
|
|
for i, short := range shorts {
|
|
shortResponses[i] = newShortResponse(r, short)
|
|
}
|
|
|
|
return shortResponses
|
|
}
|