feat: add a new endpoint - GET /api/v1/versions/:code/servers

This commit is contained in:
Dawid Wysokiński 2022-08-25 06:40:30 +02:00
parent f291c2f22c
commit 4487aa8e06
Signed by: Kichiyaki
GPG Key ID: B5445E357FB8B892
9 changed files with 154 additions and 68 deletions

View File

@ -10,7 +10,7 @@ import (
"syscall" "syscall"
"time" "time"
httpSwagger "github.com/swaggo/http-swagger" "github.com/kelseyhightower/envconfig"
"gitea.dwysokinski.me/twhelp/core/internal/rest" "gitea.dwysokinski.me/twhelp/core/internal/rest"
@ -19,9 +19,6 @@ import (
"github.com/uptrace/bun" "github.com/uptrace/bun"
"github.com/go-chi/cors"
"github.com/Kichiyaki/appmode/v2"
"github.com/Kichiyaki/chizap" "github.com/Kichiyaki/chizap"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
@ -29,8 +26,6 @@ import (
"gitea.dwysokinski.me/twhelp/core/cmd/twhelp/internal" "gitea.dwysokinski.me/twhelp/core/cmd/twhelp/internal"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
_ "gitea.dwysokinski.me/twhelp/core/cmd/twhelp/internal/serve/internal/docs"
) )
const ( const (
@ -40,7 +35,6 @@ const (
writeTimeout = 2 * time.Second writeTimeout = 2 * time.Second
idleTimeout = 2 * time.Second idleTimeout = 2 * time.Second
serverShutdownTimeout = 10 * time.Second serverShutdownTimeout = 10 * time.Second
corsMaxAge = 300
) )
func New() *cli.Command { func New() *cli.Command {
@ -59,8 +53,9 @@ func New() *cli.Command {
logger := zap.L() logger := zap.L()
srv, err := newServer(serverConfig{ srv, err := newServer(serverConfig{
logger: logger, appVersion: c.App.Version,
db: db, logger: logger,
db: db,
}) })
if err != nil { if err != nil {
return fmt.Errorf("newServer: %w", err) return fmt.Errorf("newServer: %w", err)
@ -83,24 +78,41 @@ func New() *cli.Command {
} }
type serverConfig struct { type serverConfig struct {
logger *zap.Logger appVersion string
db *bun.DB logger *zap.Logger
db *bun.DB
} }
func newServer(cfg serverConfig) (*http.Server, error) { func newServer(cfg serverConfig) (*http.Server, error) {
apiCfg, err := newAPIConfig()
if err != nil {
return nil, fmt.Errorf("newAPIConfig: %w", err)
}
// repos // repos
versionRepo := bundb.NewVersion(cfg.db) versionRepo := bundb.NewVersion(cfg.db)
serverRepo := bundb.NewServer(cfg.db)
// services // services
versionSvc := service.NewVersion(versionRepo) versionSvc := service.NewVersion(versionRepo)
serverSvc := service.NewServer(serverRepo, internal.NewTWClient(cfg.appVersion))
// router // router
r := chi.NewRouter() r := chi.NewRouter()
r.Use(getChiMiddlewares(cfg.logger)...) r.Use(getChiMiddlewares(cfg.logger)...)
rest.NewVersion(versionSvc).Register(r) r.Mount("/api", rest.NewRouter(rest.RouterConfig{
VersionService: versionSvc,
r.Get("/swagger/*", httpSwagger.Handler()) ServerService: serverSvc,
CORS: rest.CORSConfig{
Enabled: apiCfg.CORSEnabled,
AllowedOrigins: nil,
AllowedMethods: apiCfg.CORSAllowedMethods,
AllowCredentials: apiCfg.CORSAllowCredentials,
MaxAge: int(apiCfg.CORSMaxAge),
},
Swagger: apiCfg.EnableSwagger,
}))
return &http.Server{ return &http.Server{
Addr: ":" + defaultPort, Addr: ":" + defaultPort,
@ -112,30 +124,30 @@ func newServer(cfg serverConfig) (*http.Server, error) {
}, nil }, nil
} }
type apiConfig struct {
EnableSwagger bool `envconfig:"ENABLE_SWAGGER" default:"true"`
CORSEnabled bool `envconfig:"CORS_ENABLED" default:"false"`
CORSAllowedOrigins []string `envconfig:"CORS_ALLOWED_ORIGINS" default:""`
CORSAllowCredentials bool `envconfig:"CORS_ALLOW_CREDENTIALS" default:"false"`
CORSAllowedMethods []string `envconfig:"CORS_ALLOWED_METHODS" default:"HEAD,GET"`
CORSMaxAge uint32 `envconfig:"CORS_MAX_AGE" default:"300"`
}
func newAPIConfig() (apiConfig, error) {
var cfg apiConfig
if err := envconfig.Process("API", &cfg); err != nil {
return apiConfig{}, fmt.Errorf("envconfig.Process")
}
return cfg, nil
}
func getChiMiddlewares(logger *zap.Logger) chi.Middlewares { func getChiMiddlewares(logger *zap.Logger) chi.Middlewares {
mws := chi.Middlewares{ return chi.Middlewares{
middleware.RealIP, middleware.RealIP,
chizap.Logger(logger), chizap.Logger(logger),
middleware.Recoverer, middleware.Recoverer,
middleware.Heartbeat("/health"), middleware.Heartbeat("/health"),
} }
if appmode.Equal(appmode.DevelopmentMode) {
mws = append(mws, newCORSMiddlewareForDevMode())
}
return mws
}
func newCORSMiddlewareForDevMode() func(next http.Handler) http.Handler {
return cors.Handler(cors.Options{
AllowOriginFunc: func(*http.Request, string) bool {
return true
},
AllowCredentials: true,
ExposedHeaders: []string{"Authorization"},
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"},
AllowedHeaders: []string{"Origin", "Content-Length", "Content-Type", "Authorization"},
MaxAge: corsMaxAge,
})
} }
func runServer(srv *http.Server, logger *zap.Logger) { func runServer(srv *http.Server, logger *zap.Logger) {

View File

@ -1,3 +1,3 @@
docs.go docs.go
swagger.json
swagger.yaml swagger.yaml
swagger.json

View File

@ -1,7 +1,7 @@
package model package model
type APIError struct { type APIError struct {
Code string `json:"code" enums:"entity-not-found,validation-error,internal-server-error"` Code string `json:"code" enums:"entity-not-found,route-not-found,method-not-allowed,validation-error,internal-server-error"`
Message string `json:"message"` Message string `json:"message"`
} // @name ApiError } // @name ApiError

View File

@ -7,8 +7,8 @@ import (
) )
type Server struct { type Server struct {
Key string `json:"key"` Key string `json:"key" example:"pl151"`
URL string `json:"url"` URL string `json:"url" example:"https://pl151.plemiona.pl"`
Open bool `json:"open"` Open bool `json:"open"`
NumPlayers int64 `json:"numPlayers"` NumPlayers int64 `json:"numPlayers"`
NumTribes int64 `json:"numTribes"` NumTribes int64 `json:"numTribes"`

View File

@ -3,10 +3,10 @@ package model
import "gitea.dwysokinski.me/twhelp/core/internal/domain" import "gitea.dwysokinski.me/twhelp/core/internal/domain"
type Version struct { type Version struct {
Code string `json:"code"` Code string `json:"code" example:"pl"`
Name string `json:"name"` Name string `json:"name" example:"Poland"`
Host string `json:"host"` Host string `json:"host" example:"plemiona.pl"`
Timezone string `json:"timezone"` Timezone string `json:"timezone" example:"Europe/Warsaw"`
} // @name Version } // @name Version
func NewVersion(v domain.Version) Version { func NewVersion(v domain.Version) Version {

View File

@ -1,6 +1,6 @@
package serve package rest
//go:generate swag init -g openapi.go -d "./,../../../../internal/rest" -o "./internal/docs" //go:generate swag init -g openapi.go -o "./internal/docs"
// @title TWHelp API // @title TWHelp API
// @version 1.0 // @version 1.0

View File

@ -6,9 +6,16 @@ import (
"net/http" "net/http"
"strconv" "strconv"
httpSwagger "github.com/swaggo/http-swagger"
"github.com/go-chi/cors"
"github.com/go-chi/chi/v5"
"gitea.dwysokinski.me/twhelp/core/internal/rest/internal/model" "gitea.dwysokinski.me/twhelp/core/internal/rest/internal/model"
"gitea.dwysokinski.me/twhelp/core/internal/domain" "gitea.dwysokinski.me/twhelp/core/internal/domain"
_ "gitea.dwysokinski.me/twhelp/core/internal/rest/internal/docs"
) )
//go:generate counterfeiter -generate //go:generate counterfeiter -generate
@ -20,6 +27,82 @@ var (
) )
) )
type CORSConfig struct {
Enabled bool
AllowedOrigins []string
AllowedMethods []string
AllowCredentials bool
MaxAge int
}
type RouterConfig struct {
VersionService VersionService
ServerService ServerService
CORS CORSConfig
Swagger bool
}
func NewRouter(cfg RouterConfig) *chi.Mux {
// handlers
versionHandler := &version{
svc: cfg.VersionService,
}
serverHandler := &Server{
svc: cfg.ServerService,
}
router := chi.NewRouter()
if cfg.CORS.Enabled {
router.Use(cors.Handler(cors.Options{
AllowedOrigins: cfg.CORS.AllowedOrigins,
AllowCredentials: cfg.CORS.AllowCredentials,
AllowedMethods: cfg.CORS.AllowedMethods,
AllowedHeaders: []string{"Origin", "Content-Length", "Content-Type"},
MaxAge: cfg.CORS.MaxAge,
}))
}
router.NotFound(routeNotFoundHandler)
router.MethodNotAllowed(methodNotAllowedHandler)
router.Route("/v1", func(r chi.Router) {
r.Route("/versions", func(r chi.Router) {
r.Get("/", versionHandler.list)
r.Route("/{versionCode}", func(r chi.Router) {
r.Get("/", versionHandler.getByCode)
r.Route("/servers", func(r chi.Router) {
r.Get("/", serverHandler.list)
})
})
})
})
if cfg.Swagger {
router.Get("/swagger/*", httpSwagger.Handler())
}
return router
}
func routeNotFoundHandler(w http.ResponseWriter, _ *http.Request) {
renderJSON(w, http.StatusNotFound, model.ErrorResp{
Error: model.APIError{
Code: "route-not-found",
Message: "route not found",
},
})
}
func methodNotAllowedHandler(w http.ResponseWriter, _ *http.Request) {
renderJSON(w, http.StatusMethodNotAllowed, model.ErrorResp{
Error: model.APIError{
Code: "method-not-allowed",
Message: "method not allowed",
},
})
}
func renderErr(w http.ResponseWriter, err error) { func renderErr(w http.ResponseWriter, err error) {
var convError domain.Error var convError domain.Error
if !errors.As(err, &convError) { if !errors.As(err, &convError) {

View File

@ -17,19 +17,10 @@ type VersionService interface {
GetByCode(ctx context.Context, code string) (domain.Version, error) GetByCode(ctx context.Context, code string) (domain.Version, error)
} }
type Version struct { type version struct {
svc VersionService svc VersionService
} }
func NewVersion(svc VersionService) *Version {
return &Version{svc: svc}
}
func (v *Version) Register(r chi.Router) {
r.Get("/api/v1/versions", v.list)
r.Get("/api/v1/versions/{versionCode}", v.getByCode)
}
// @ID listVersions // @ID listVersions
// @Summary List versions // @Summary List versions
// @Description List all versions // @Description List all versions
@ -39,7 +30,7 @@ func (v *Version) Register(r chi.Router) {
// @Failure 500 {object} model.ErrorResp // @Failure 500 {object} model.ErrorResp
// @Header 200 {integer} X-Total-Count "Total number of records" // @Header 200 {integer} X-Total-Count "Total number of records"
// @Router /versions [get] // @Router /versions [get]
func (v *Version) list(w http.ResponseWriter, r *http.Request) { func (v *version) list(w http.ResponseWriter, r *http.Request) {
versions, count, err := v.svc.List(r.Context(), domain.ListVersionsParams{Count: true}) versions, count, err := v.svc.List(r.Context(), domain.ListVersionsParams{Count: true})
if err != nil { if err != nil {
renderErr(w, err) renderErr(w, err)
@ -59,12 +50,12 @@ func (v *Version) list(w http.ResponseWriter, r *http.Request) {
// @Failure 500 {object} model.ErrorResp // @Failure 500 {object} model.ErrorResp
// @Param versionCode path string true "Version code" // @Param versionCode path string true "Version code"
// @Router /versions/{versionCode} [get] // @Router /versions/{versionCode} [get]
func (v *Version) getByCode(w http.ResponseWriter, r *http.Request) { func (v *version) getByCode(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
version, err := v.svc.GetByCode(ctx, chi.URLParamFromCtx(ctx, "versionCode")) ver, err := v.svc.GetByCode(ctx, chi.URLParamFromCtx(ctx, "versionCode"))
if err != nil { if err != nil {
renderErr(w, err) renderErr(w, err)
return return
} }
renderJSON(w, http.StatusOK, model.NewGetVersionByCodeResp(version)) renderJSON(w, http.StatusOK, model.NewGetVersionByCodeResp(ver))
} }

View File

@ -9,8 +9,6 @@ import (
"gitea.dwysokinski.me/twhelp/core/internal/domain" "gitea.dwysokinski.me/twhelp/core/internal/domain"
"gitea.dwysokinski.me/twhelp/core/internal/rest/internal/mock" "gitea.dwysokinski.me/twhelp/core/internal/rest/internal/mock"
"gitea.dwysokinski.me/twhelp/core/internal/rest/internal/model" "gitea.dwysokinski.me/twhelp/core/internal/rest/internal/model"
"github.com/go-chi/chi/v5"
) )
func TestVersionHandler_list(t *testing.T) { func TestVersionHandler_list(t *testing.T) {
@ -83,15 +81,16 @@ func TestVersionHandler_list(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel() t.Parallel()
svc := mock.FakeVersionService{} svc := &mock.FakeVersionService{}
if tt.setup != nil { if tt.setup != nil {
tt.setup(&svc) tt.setup(svc)
} }
router := chi.NewRouter() router := rest.NewRouter(rest.RouterConfig{
rest.NewVersion(&svc).Register(router) VersionService: svc,
})
resp := doRequest(router, http.MethodGet, "/api/v1/versions", nil) resp := doRequest(router, http.MethodGet, "/v1/versions", nil)
defer resp.Body.Close() defer resp.Body.Close()
assertTotalCount(t, resp.Header, tt.expectedTotalCount) assertTotalCount(t, resp.Header, tt.expectedTotalCount)
assertJSONResponse(t, resp, tt.expectedStatus, tt.expectedResponse, tt.target) assertJSONResponse(t, resp, tt.expectedStatus, tt.expectedResponse, tt.target)
@ -155,15 +154,16 @@ func TestVersion_getByCode(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel() t.Parallel()
svc := mock.FakeVersionService{} svc := &mock.FakeVersionService{}
if tt.setup != nil { if tt.setup != nil {
tt.setup(&svc) tt.setup(svc)
} }
router := chi.NewRouter() router := rest.NewRouter(rest.RouterConfig{
rest.NewVersion(&svc).Register(router) VersionService: svc,
})
resp := doRequest(router, http.MethodGet, "/api/v1/versions/"+tt.code, nil) resp := doRequest(router, http.MethodGet, "/v1/versions/"+tt.code, nil)
defer resp.Body.Close() defer resp.Body.Close()
assertJSONResponse(t, resp, tt.expectedStatus, tt.expectedResponse, tt.target) assertJSONResponse(t, resp, tt.expectedStatus, tt.expectedResponse, tt.target)
}) })