diff --git a/.github/workflows/frontend.yaml b/.github/workflows/frontend.yaml index 34abb7d..db769ae 100644 --- a/.github/workflows/frontend.yaml +++ b/.github/workflows/frontend.yaml @@ -24,6 +24,19 @@ jobs: with: version: 6.0.2 + - name: Set up Go + uses: actions/setup-go@v2 + with: + go-version: 1.18 + + - uses: actions/setup-node@v3 + with: + node-version: 18 + + - uses: pnpm/action-setup@v2.2.2 + with: + version: 6.0.2 + - name: Checkout uses: actions/checkout@v2 with: diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml index c6ba7c2..3e28501 100644 --- a/.github/workflows/go.yaml +++ b/.github/workflows/go.yaml @@ -27,7 +27,6 @@ jobs: version: latest # Optional: working directory, useful for monorepos - timeout: 5m working-directory: backend - name: Build API diff --git a/.gitignore b/.gitignore index ad0d618..096f82c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ # Project Specific -api.log config.yml -ent.db +homebox.db .idea .vscode diff --git a/Dockerfile b/Dockerfile index 829d5f2..805420e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,8 +7,6 @@ RUN pnpm install --frozen-lockfile --shamefully-hoist COPY frontend . RUN pnpm build - - # Build API FROM golang:alpine AS builder RUN apk update @@ -20,7 +18,6 @@ COPY --from=frontend-builder app/.output go/src/app/app/api/public RUN go get -d -v ./... RUN CGO_ENABLED=1 GOOS=linux go build -o /go/bin/api -v ./app/api/*.go - # Production Stage FROM alpine:latest diff --git a/backend/app/api/app.go b/backend/app/api/app.go index 8e1f742..7176758 100644 --- a/backend/app/api/app.go +++ b/backend/app/api/app.go @@ -20,7 +20,7 @@ type app struct { services *services.AllServices } -func NewApp(conf *config.Config) *app { +func new(conf *config.Config) *app { s := &app{ conf: conf, } @@ -36,7 +36,7 @@ func NewApp(conf *config.Config) *app { return s } -func (a *app) StartBgTask(t time.Duration, fn func()) { +func (a *app) startBgTask(t time.Duration, fn func()) { for { a.server.Background(fn) time.Sleep(t) diff --git a/backend/app/api/logger.go b/backend/app/api/logger.go new file mode 100644 index 0000000..b410cd1 --- /dev/null +++ b/backend/app/api/logger.go @@ -0,0 +1,41 @@ +package main + +import ( + "os" + "strings" + + "github.com/hay-kot/content/backend/internal/config" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +// setupLogger initializes the zerolog config +// for the shared logger. +func (a *app) setupLogger() { + // Logger Init + // zerolog.TimeFieldFormat = zerolog.TimeFormatUnix + if a.conf.Mode != config.ModeProduction { + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) + } + + log.Level(getLevel(a.conf.Log.Level)) +} + +func getLevel(l string) zerolog.Level { + switch strings.ToLower(l) { + case "debug": + return zerolog.DebugLevel + case "info": + return zerolog.InfoLevel + case "warn": + return zerolog.WarnLevel + case "error": + return zerolog.ErrorLevel + case "fatal": + return zerolog.FatalLevel + case "panic": + return zerolog.PanicLevel + default: + return zerolog.InfoLevel + } +} diff --git a/backend/app/api/main.go b/backend/app/api/main.go index 99a668a..d06faa0 100644 --- a/backend/app/api/main.go +++ b/backend/app/api/main.go @@ -2,7 +2,6 @@ package main import ( "context" - "os" "time" "github.com/hay-kot/content/backend/app/api/docs" @@ -12,7 +11,6 @@ import ( "github.com/hay-kot/content/backend/internal/services" "github.com/hay-kot/content/backend/pkgs/server" _ "github.com/mattn/go-sqlite3" - "github.com/rs/zerolog" "github.com/rs/zerolog/log" ) @@ -27,10 +25,6 @@ import ( // @name Authorization // @description "Type 'Bearer TOKEN' to correctly set the API Key" func main() { - // Logger Init - // zerolog.TimeFieldFormat = zerolog.TimeFormatUnix - log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) - log.Level(zerolog.DebugLevel) cfgFile := "config.yml" @@ -47,8 +41,9 @@ func main() { } func run(cfg *config.Config) error { - app := NewApp(cfg) + app := new(cfg) + app.setupLogger() // ========================================================================= // Initialize Database & Repos @@ -75,22 +70,19 @@ func run(cfg *config.Config) error { // ========================================================================= // Start Server - - app.conf.Print() - app.server = server.NewServer(app.conf.Web.Host, app.conf.Web.Port) - routes := app.newRouter(app.repos) - app.LogRoutes(routes) - app.SeedDatabase(app.repos) + if app.conf.Mode != config.ModeDevelopment { + app.logRoutes(routes) + } log.Info().Msgf("Starting HTTP Server on %s:%s", app.server.Host, app.server.Port) // ========================================================================= // Start Reoccurring Tasks - go app.StartBgTask(time.Duration(24)*time.Hour, func() { + go app.startBgTask(time.Duration(24)*time.Hour, func() { _, err := app.repos.AuthTokens.PurgeExpiredTokens(context.Background()) if err != nil { log.Error(). diff --git a/backend/app/api/middleware.go b/backend/app/api/middleware.go index c062ec2..aec4917 100644 --- a/backend/app/api/middleware.go +++ b/backend/app/api/middleware.go @@ -87,8 +87,21 @@ func mwStripTrailingSlash(next http.Handler) http.Handler { }) } +type StatusRecorder struct { + http.ResponseWriter + Status int +} + +func (r *StatusRecorder) WriteHeader(status int) { + r.Status = status + r.ResponseWriter.WriteHeader(status) +} + func (a *app) mwStructLogger(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + record := &StatusRecorder{ResponseWriter: w, Status: http.StatusOK} + next.ServeHTTP(record, r) + scheme := "http" if r.TLS != nil { scheme = "https" @@ -101,25 +114,35 @@ func (a *app) mwStructLogger(next http.Handler) http.Handler { Str("url", url). Str("method", r.Method). Str("remote_addr", r.RemoteAddr). - Msgf("[%s] %s", r.Method, url) - next.ServeHTTP(w, r) + Int("status", record.Status). + Msg(url) }) } func (a *app) mwSummaryLogger(next http.Handler) http.Handler { - bold := func(s string) string { - return "\033[1m" + s + "\033[0m" - } + bold := func(s string) string { return "\033[1m" + s + "\033[0m" } + orange := func(s string) string { return "\033[33m" + s + "\033[0m" } + aqua := func(s string) string { return "\033[36m" + s + "\033[0m" } + red := func(s string) string { return "\033[31m" + s + "\033[0m" } + green := func(s string) string { return "\033[32m" + s + "\033[0m" } - pink := func(s string) string { - return "\033[35m" + s + "\033[0m" - } - - aqua := func(s string) string { - return "\033[36m" + s + "\033[0m" + fmtCode := func(code int) string { + switch { + case code >= 500: + return red(fmt.Sprintf("%d", code)) + case code >= 400: + return orange(fmt.Sprintf("%d", code)) + case code >= 300: + return aqua(fmt.Sprintf("%d", code)) + default: + return green(fmt.Sprintf("%d", code)) + } } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + record := &StatusRecorder{ResponseWriter: w, Status: http.StatusOK} + next.ServeHTTP(record, r) // Blocks until the next handler returns. + scheme := "http" if r.TLS != nil { scheme = "https" @@ -127,7 +150,11 @@ func (a *app) mwSummaryLogger(next http.Handler) http.Handler { url := fmt.Sprintf("%s://%s%s %s", scheme, r.Host, r.RequestURI, r.Proto) - log.Info().Msgf("%s %s", bold(pink("["+r.Method+"]")), aqua(url)) - next.ServeHTTP(w, r) + log.Info(). + Msgf("%s %s %s", + bold(orange(""+r.Method+"")), + aqua(url), + bold(fmtCode(record.Status)), + ) }) } diff --git a/backend/app/api/routes.go b/backend/app/api/routes.go index 46b8610..1ff79f0 100644 --- a/backend/app/api/routes.go +++ b/backend/app/api/routes.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "io" - "io/fs" "mime" "net/http" "path" @@ -19,21 +18,22 @@ import ( httpSwagger "github.com/swaggo/http-swagger" // http-swagger middleware ) -//go:embed all:public/* -var public embed.FS - const prefix = "/api" +var ( + ErrDir = errors.New("path is dir") + + //go:embed all:public/* + public embed.FS +) + // registerRoutes registers all the routes for the API func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux { + registerMimes() + r := chi.NewRouter() a.setGlobalMiddleware(r) - // ========================================================================= - // Base Routes - - DumpEmbedContents() - r.Get("/swagger/*", httpSwagger.Handler( httpSwagger.URL(fmt.Sprintf("%s://%s/swagger/doc.json", a.conf.Swagger.Scheme, a.conf.Swagger.Host)), )) @@ -78,14 +78,13 @@ func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux { }) } - r.NotFound(NotFoundHandler) - + r.NotFound(notFoundHandler()) return r } -// LogRoutes logs the routes of the server that are registered within Server.registerRoutes(). This is useful for debugging. +// logRoutes logs the routes of the server that are registered within Server.registerRoutes(). This is useful for debugging. // See https://github.com/go-chi/chi/issues/332 for details and inspiration. -func (a *app) LogRoutes(r *chi.Mux) { +func (a *app) logRoutes(r *chi.Mux) { desiredSpaces := 10 walkFunc := func(method string, route string, handler http.Handler, middleware ...func(http.Handler) http.Handler) error { @@ -104,9 +103,7 @@ func (a *app) LogRoutes(r *chi.Mux) { } } -var ErrDir = errors.New("path is dir") - -func init() { +func registerMimes() { err := mime.AddExtensionType(".js", "application/javascript") if err != nil { panic(err) @@ -118,48 +115,38 @@ func init() { } } -func tryRead(fs embed.FS, prefix, requestedPath string, w http.ResponseWriter) error { - f, err := fs.Open(path.Join(prefix, requestedPath)) - if err != nil { - return err - } - defer f.Close() - - stat, _ := f.Stat() - if stat.IsDir() { - return ErrDir - } - - contentType := mime.TypeByExtension(filepath.Ext(requestedPath)) - w.Header().Set("Content-Type", contentType) - _, err = io.Copy(w, f) - return err -} - -func NotFoundHandler(w http.ResponseWriter, r *http.Request) { - err := tryRead(public, "public", r.URL.Path, w) - if err == nil { - return - } - log.Debug(). - Str("path", r.URL.Path). - Msg("served from embed not found - serving index.html") - err = tryRead(public, "public", "index.html", w) - if err != nil { - panic(err) - } -} - -func DumpEmbedContents() { - // recursively prints all contents in the embed.FS - err := fs.WalkDir(public, ".", func(path string, d fs.DirEntry, err error) error { +// notFoundHandler perform the main logic around handling the internal SPA embed and ensuring that +// the client side routing is handled correctly. +func notFoundHandler() http.HandlerFunc { + tryRead := func(fs embed.FS, prefix, requestedPath string, w http.ResponseWriter) error { + f, err := fs.Open(path.Join(prefix, requestedPath)) if err != nil { return err } - fmt.Println(path) - return nil - }) - if err != nil { - panic(err) + defer f.Close() + + stat, _ := f.Stat() + if stat.IsDir() { + return ErrDir + } + + contentType := mime.TypeByExtension(filepath.Ext(requestedPath)) + w.Header().Set("Content-Type", contentType) + _, err = io.Copy(w, f) + return err + } + + return func(w http.ResponseWriter, r *http.Request) { + err := tryRead(public, "public", r.URL.Path, w) + if err == nil { + return + } + log.Debug(). + Str("path", r.URL.Path). + Msg("served from embed not found - serving index.html") + err = tryRead(public, "public", "index.html", w) + if err != nil { + panic(err) + } } } diff --git a/backend/app/api/seed.go b/backend/app/api/seed.go deleted file mode 100644 index 76d9daa..0000000 --- a/backend/app/api/seed.go +++ /dev/null @@ -1,96 +0,0 @@ -package main - -import ( - "context" - - "github.com/google/uuid" - "github.com/hay-kot/content/backend/ent" - "github.com/hay-kot/content/backend/internal/repo" - "github.com/hay-kot/content/backend/internal/types" - "github.com/hay-kot/content/backend/pkgs/hasher" - "github.com/rs/zerolog/log" -) - -const ( - DefaultGroup = "Default" - DefaultName = "Admin" - DefaultEmail = "admin@admin.com" - DefaultPassword = "admin" -) - -// EnsureAdministrator ensures that there is at least one superuser in the database -// if one isn't found a default is generate using the default credentials -func (a *app) EnsureAdministrator() { - superusers, err := a.repos.Users.GetSuperusers(context.Background()) - - if err != nil { - log.Fatal().Err(err).Msg("failed to get superusers") - } - if len(superusers) > 0 { - return - } - - pw, _ := hasher.HashPassword(DefaultPassword) - newSuperUser := types.UserCreate{ - Name: DefaultName, - Email: DefaultEmail, - IsSuperuser: true, - Password: pw, - } - - log.Info(). - Str("name", newSuperUser.Name). - Str("email", newSuperUser.Email). - Msg("no superusers found, creating default superuser") - - _, err = a.repos.Users.Create(context.Background(), newSuperUser) - - if err != nil { - log.Fatal().Err(err).Msg("failed to create default superuser") - } - -} - -func (a *app) SeedDatabase(repos *repo.AllRepos) { - if !a.conf.Seed.Enabled { - return - } - - group, err := repos.Groups.Create(context.Background(), DefaultGroup) - if err != nil { - log.Fatal().Err(err).Msg("failed to create default group") - } - - for _, seedUser := range a.conf.Seed.Users { - - // Check if User Exists - usr, err := repos.Users.GetOneEmail(context.Background(), seedUser.Email) - if err != nil && !ent.IsNotFound(err) { - log.Fatal().Err(err).Msg("failed to get user") - } - - if usr != nil && usr.ID != uuid.Nil { - log.Info().Str("email", seedUser.Email).Msg("user already exists, skipping") - continue - } - - hashedPw, err := hasher.HashPassword(seedUser.Password) - if err != nil { - log.Fatal().Err(err).Msg("failed to hash password") - } - - _, err = repos.Users.Create(context.Background(), types.UserCreate{ - Name: seedUser.Name, - Email: seedUser.Email, - IsSuperuser: seedUser.IsSuperuser, - Password: hashedPw, - GroupID: group.ID, - }) - - if err != nil { - log.Fatal().Err(err).Msg("failed to create user") - } - - log.Info().Str("email", seedUser.Email).Msg("created user") - } -} diff --git a/backend/app/api/v1/v1_ctrl_labels.go b/backend/app/api/v1/v1_ctrl_labels.go index 7cac7c5..359d385 100644 --- a/backend/app/api/v1/v1_ctrl_labels.go +++ b/backend/app/api/v1/v1_ctrl_labels.go @@ -3,6 +3,7 @@ package v1 import ( "net/http" + "github.com/hay-kot/content/backend/ent" "github.com/hay-kot/content/backend/internal/services" "github.com/hay-kot/content/backend/internal/types" "github.com/hay-kot/content/backend/pkgs/server" @@ -101,6 +102,13 @@ func (ctrl *V1Controller) HandleLabelGet() http.HandlerFunc { labels, err := ctrl.svc.Labels.Get(r.Context(), user.GroupID, uid) if err != nil { + if ent.IsNotFound(err) { + log.Err(err). + Str("id", uid.String()). + Msg("label not found") + server.RespondError(w, http.StatusNotFound, err) + return + } log.Err(err).Msg("error getting label") server.RespondServerError(w) return diff --git a/backend/app/api/v1/v1_ctrl_locations.go b/backend/app/api/v1/v1_ctrl_locations.go index 275679b..38de872 100644 --- a/backend/app/api/v1/v1_ctrl_locations.go +++ b/backend/app/api/v1/v1_ctrl_locations.go @@ -3,6 +3,7 @@ package v1 import ( "net/http" + "github.com/hay-kot/content/backend/ent" "github.com/hay-kot/content/backend/internal/services" "github.com/hay-kot/content/backend/internal/types" "github.com/hay-kot/content/backend/pkgs/server" @@ -101,6 +102,14 @@ func (ctrl *V1Controller) HandleLocationGet() http.HandlerFunc { location, err := ctrl.svc.Location.GetOne(r.Context(), user.GroupID, uid) if err != nil { + if ent.IsNotFound(err) { + log.Err(err). + Str("id", uid.String()). + Msg("location not found") + server.RespondError(w, http.StatusNotFound, err) + return + } + log.Err(err).Msg("failed to get location") server.RespondServerError(w) return diff --git a/backend/config.template.yml b/backend/config.template.yml index 366e2a3..7ee50a6 100644 --- a/backend/config.template.yml +++ b/backend/config.template.yml @@ -5,28 +5,15 @@ swagger: scheme: http web: port: 7745 - host: + host: "" database: driver: sqlite3 - sqlite-url: ./ent.db?_fk=1 + sqlite-url: ./homebox.db?_fk=1 logger: level: debug - file: api.log mailer: host: smtp.example.com port: 465 username: password: from: example@email.com -seed: - enabled: true - group: Default - users: - - name: Admin - email: admin@admin.com - password: admin - isSuperuser: true - - name: User - email: user@user.com - password: user - isSuperuser: false diff --git a/backend/internal/config/conf.go b/backend/internal/config/conf.go index 5f6c7e1..79edadd 100644 --- a/backend/internal/config/conf.go +++ b/backend/internal/config/conf.go @@ -22,7 +22,6 @@ type Config struct { Database Database `yaml:"database"` Log LoggerConf `yaml:"logger"` Mailer MailerConf `yaml:"mailer"` - Seed Seed `yaml:"seed"` Swagger SwaggerConf `yaml:"swagger"` } diff --git a/backend/internal/config/conf_database.go b/backend/internal/config/conf_database.go index d8a6c7b..7b396bd 100644 --- a/backend/internal/config/conf_database.go +++ b/backend/internal/config/conf_database.go @@ -1,14 +1,12 @@ package config const ( - DriverSqlite3 = "sqlite3" - DriverPostgres = "postgres" + DriverSqlite3 = "sqlite3" ) type Database struct { - Driver string `yaml:"driver" conf:"default:sqlite3"` - SqliteUrl string `yaml:"sqlite-url" conf:"default:file:ent?mode=memory&cache=shared&_fk=1"` - PostgresUrl string `yaml:"postgres-url" conf:""` + Driver string `yaml:"driver" conf:"default:sqlite3"` + SqliteUrl string `yaml:"sqlite-url" conf:"default:file:ent?mode=memory&cache=shared&_fk=1"` } func (d *Database) GetDriver() string { @@ -19,8 +17,6 @@ func (d *Database) GetUrl() string { switch d.Driver { case DriverSqlite3: return d.SqliteUrl - case DriverPostgres: - return d.PostgresUrl default: panic("unknown database driver") } diff --git a/backend/internal/config/conf_database_test.go b/backend/internal/config/conf_database_test.go index 4720a15..f11c21c 100644 --- a/backend/internal/config/conf_database_test.go +++ b/backend/internal/config/conf_database_test.go @@ -16,16 +16,6 @@ func Test_DatabaseConfig_Sqlite(t *testing.T) { assert.Equal(t, "file:ent?mode=memory&cache=shared&_fk=1", dbConf.GetUrl()) } -func Test_DatabaseConfig_Postgres(t *testing.T) { - dbConf := &Database{ - Driver: DriverPostgres, - PostgresUrl: "postgres://user:pass@host:port/dbname?sslmode=disable", - } - - assert.Equal(t, "postgres", dbConf.GetDriver()) - assert.Equal(t, "postgres://user:pass@host:port/dbname?sslmode=disable", dbConf.GetUrl()) -} - func Test_DatabaseConfig_Unknown(t *testing.T) { dbConf := &Database{ Driver: "null", diff --git a/backend/internal/config/conf_seed.go b/backend/internal/config/conf_seed.go deleted file mode 100644 index 67a409a..0000000 --- a/backend/internal/config/conf_seed.go +++ /dev/null @@ -1,14 +0,0 @@ -package config - -type SeedUser struct { - Name string `yaml:"name"` - Email string `yaml:"email"` - Password string `yaml:"password"` - IsSuperuser bool `yaml:"isSuperuser"` -} - -type Seed struct { - Enabled bool `yaml:"enabled" conf:"default:false"` - Users []SeedUser `yaml:"users"` - Group string `yaml:"group"` -} diff --git a/backend/internal/repo/repo_users.go b/backend/internal/repo/repo_users.go index 8fc348e..66cd7ac 100644 --- a/backend/internal/repo/repo_users.go +++ b/backend/internal/repo/repo_users.go @@ -28,18 +28,11 @@ func (e *EntUserRepository) GetOneEmail(ctx context.Context, email string) (*ent } func (e *EntUserRepository) GetAll(ctx context.Context) ([]*ent.User, error) { - users, err := e.db.User.Query().WithGroup().All(ctx) - - if err != nil { - return nil, err - } - - return users, nil + return e.db.User.Query().WithGroup().All(ctx) } func (e *EntUserRepository) Create(ctx context.Context, usr types.UserCreate) (*ent.User, error) { err := usr.Validate() - if err != nil { return &ent.User{}, err } @@ -52,7 +45,6 @@ func (e *EntUserRepository) Create(ctx context.Context, usr types.UserCreate) (* SetIsSuperuser(usr.IsSuperuser). SetGroupID(usr.GroupID). Save(ctx) - if err != nil { return entUser, err } diff --git a/backend/internal/services/service_user.go b/backend/internal/services/service_user.go index 80c7d10..b5ced21 100644 --- a/backend/internal/services/service_user.go +++ b/backend/internal/services/service_user.go @@ -23,6 +23,8 @@ type UserService struct { repos *repo.AllRepos } +// RegisterUser creates a new user and group in the data with the provided data. It also bootstraps the user's group +// with default Labels and Locations. func (svc *UserService) RegisterUser(ctx context.Context, data types.UserRegistration) (*types.UserOut, error) { group, err := svc.repos.Groups.Create(ctx, data.GroupName) if err != nil { @@ -38,7 +40,26 @@ func (svc *UserService) RegisterUser(ctx context.Context, data types.UserRegistr GroupID: group.ID, } - return mappers.ToOutUser(svc.repos.Users.Create(ctx, usrCreate)) + usr, err := svc.repos.Users.Create(ctx, usrCreate) + if err != nil { + return &types.UserOut{}, err + } + + for _, label := range defaultLabels() { + _, err := svc.repos.Labels.Create(ctx, group.ID, label) + if err != nil { + return &types.UserOut{}, err + } + } + + for _, location := range defaultLocations() { + _, err := svc.repos.Locations.Create(ctx, group.ID, location) + if err != nil { + return &types.UserOut{}, err + } + } + + return mappers.ToOutUser(usr, nil) } // GetSelf returns the user that is currently logged in based of the token provided within diff --git a/backend/internal/services/service_user_defaults.go b/backend/internal/services/service_user_defaults.go new file mode 100644 index 0000000..185c782 --- /dev/null +++ b/backend/internal/services/service_user_defaults.go @@ -0,0 +1,55 @@ +package services + +import "github.com/hay-kot/content/backend/internal/types" + +func defaultLocations() []types.LocationCreate { + return []types.LocationCreate{ + { + Name: "Living Room", + }, + { + Name: "Garage", + }, + { + Name: "Kitchen", + }, + { + Name: "Bedroom", + }, + { + Name: "Bathroom", + }, + { + Name: "Office", + }, + { + Name: "Attic", + }, + { + Name: "Basement", + }, + } +} + +func defaultLabels() []types.LabelCreate { + return []types.LabelCreate{ + { + Name: "Appliances", + }, + { + Name: "IOT", + }, + { + Name: "Electronics", + }, + { + Name: "Servers", + }, + { + Name: "General", + }, + { + Name: "Important", + }, + } +} diff --git a/frontend/components/Icon.vue b/frontend/components/Icon.vue index ecdae33..da66a6a 100644 --- a/frontend/components/Icon.vue +++ b/frontend/components/Icon.vue @@ -25,7 +25,7 @@ diff --git a/frontend/composables/use-api.ts b/frontend/composables/use-api.ts index 3e63488..87be12c 100644 --- a/frontend/composables/use-api.ts +++ b/frontend/composables/use-api.ts @@ -1,23 +1,27 @@ -import { PublicApi } from "~~/lib/api/public"; -import { UserApi } from "~~/lib/api/user"; -import { Requests } from "~~/lib/requests"; -import { useAuthStore } from "~~/stores/auth"; +import { PublicApi } from '~~/lib/api/public'; +import { UserApi } from '~~/lib/api/user'; +import { Requests } from '~~/lib/requests'; +import { useAuthStore } from '~~/stores/auth'; -async function ApiDebugger(r: Response) { - console.table({ - "Request Url": r.url, - "Response Status": r.status, - "Response Status Text": r.statusText, - }); +function logger(r: Response) { + console.log(`${r.status} ${r.url} ${r.statusText}`); } export function usePublicApi(): PublicApi { - const requests = new Requests("", "", {}, ApiDebugger); + const requests = new Requests('', '', {}); return new PublicApi(requests); } export function useUserApi(): UserApi { const authStore = useAuthStore(); - const requests = new Requests("", () => authStore.token, {}, ApiDebugger); + + const requests = new Requests('', () => authStore.token, {}); + requests.addResponseInterceptor(logger); + requests.addResponseInterceptor(r => { + if (r.status === 401) { + authStore.clearSession(); + } + }); + return new UserApi(requests); } diff --git a/frontend/lib/api/__test__/public.test.ts b/frontend/lib/api/__test__/public.test.ts index c19f2fd..8ad7515 100644 --- a/frontend/lib/api/__test__/public.test.ts +++ b/frontend/lib/api/__test__/public.test.ts @@ -1,24 +1,8 @@ -import { describe, it, expect } from 'vitest'; -import { Requests } from '../../requests'; -import { OverrideParts } from '../base/urls'; -import { PublicApi } from '../public'; -import * as config from '../../../test/config'; -import { UserApi } from '../user'; - -function client() { - OverrideParts(config.BASE_URL, '/api/v1'); - const requests = new Requests(''); - return new PublicApi(requests); -} - -function userClient(token: string) { - OverrideParts(config.BASE_URL, '/api/v1'); - const requests = new Requests('', token); - return new UserApi(requests); -} +import { describe, test, expect } from 'vitest'; +import { client, userClient } from './test-utils'; describe('[GET] /api/v1/status', () => { - it('basic query parameter', async () => { + test('server should respond', async () => { const api = client(); const { response, data } = await api.status(); expect(response.status).toBe(200); @@ -37,12 +21,12 @@ describe('first time user workflow (register, login)', () => { }, }; - it('user should be able to register', async () => { + test('user should be able to register', async () => { const { response } = await api.register(userData); expect(response.status).toBe(204); }); - it('user should be able to login', async () => { + test('user should be able to login', async () => { const { response, data } = await api.login(userData.user.email, userData.user.password); expect(response.status).toBe(200); expect(data.token).toBeTruthy(); diff --git a/frontend/lib/api/__test__/test-utils.ts b/frontend/lib/api/__test__/test-utils.ts new file mode 100644 index 0000000..a46f21a --- /dev/null +++ b/frontend/lib/api/__test__/test-utils.ts @@ -0,0 +1,61 @@ +import { beforeAll, expect } from 'vitest'; +import { Requests } from '../../requests'; +import { overrideParts } from '../base/urls'; +import { PublicApi } from '../public'; +import * as config from '../../../test/config'; +import { UserApi } from '../user'; + +export function client() { + overrideParts(config.BASE_URL, '/api/v1'); + const requests = new Requests(''); + return new PublicApi(requests); +} + +export function userClient(token: string) { + overrideParts(config.BASE_URL, '/api/v1'); + const requests = new Requests('', token); + return new UserApi(requests); +} + +const cache = { + token: '', +}; + +/* + * Shared UserApi token for tests where the creation of a user is _not_ import + * to the test. This is useful for tests that are testing the user API itself. + */ +export async function sharedUserClient(): Promise { + if (cache.token) { + return userClient(cache.token); + } + const testUser = { + groupName: 'test-group', + user: { + email: '__test__@__test__.com', + name: '__test__', + password: '__test__', + }, + }; + + const api = client(); + const { response: tryLoginResp, data } = await api.login(testUser.user.email, testUser.user.password); + + if (tryLoginResp.status === 200) { + cache.token = data.token; + return userClient(cache.token); + } + + const { response: registerResp } = await api.register(testUser); + expect(registerResp.status).toBe(204); + + const { response: loginResp, data: loginData } = await api.login(testUser.user.email, testUser.user.password); + expect(loginResp.status).toBe(200); + + cache.token = loginData.token; + return userClient(data.token); +} + +beforeAll(async () => { + await sharedUserClient(); +}); diff --git a/frontend/lib/api/__test__/user/labels.test.ts b/frontend/lib/api/__test__/user/labels.test.ts new file mode 100644 index 0000000..473cc62 --- /dev/null +++ b/frontend/lib/api/__test__/user/labels.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from 'vitest'; +import { Label } from '../../classes/labels'; +import { UserApi } from '../../user'; +import { sharedUserClient } from '../test-utils'; + +describe('locations lifecycle (create, update, delete)', () => { + let increment = 0; + + /** + * useLabel sets up a label resource for testing, and returns a function + * that can be used to delete the label from the backend server. + */ + async function useLabel(api: UserApi): Promise<[Label, () => Promise]> { + const { response, data } = await api.labels.create({ + name: `__test__.label.name_${increment}`, + description: `__test__.label.description_${increment}`, + color: '', + }); + expect(response.status).toBe(201); + increment++; + + const cleanup = async () => { + const { response } = await api.labels.delete(data.id); + expect(response.status).toBe(204); + }; + return [data, cleanup]; + } + + test('user should be able to create a label', async () => { + const api = await sharedUserClient(); + + const labelData = { + name: 'test-label', + description: 'test-description', + color: '', + }; + + const { response, data } = await api.labels.create(labelData); + + expect(response.status).toBe(201); + expect(data.id).toBeTruthy(); + + // Ensure we can get the label + const { response: getResponse, data: getData } = await api.labels.get(data.id); + + expect(getResponse.status).toBe(200); + expect(getData.id).toBe(data.id); + expect(getData.name).toBe(labelData.name); + expect(getData.description).toBe(labelData.description); + + // Cleanup + const { response: deleteResponse } = await api.labels.delete(data.id); + expect(deleteResponse.status).toBe(204); + }); + + test('user should be able to update a label', async () => { + const api = await sharedUserClient(); + const [label, cleanup] = await useLabel(api); + + const labelData = { + name: 'test-label', + description: 'test-description', + color: '', + }; + + const { response, data } = await api.labels.update(label.id, labelData); + expect(response.status).toBe(200); + expect(data.id).toBe(label.id); + + // Ensure we can get the label + const { response: getResponse, data: getData } = await api.labels.get(data.id); + expect(getResponse.status).toBe(200); + expect(getData.id).toBe(data.id); + expect(getData.name).toBe(labelData.name); + expect(getData.description).toBe(labelData.description); + + // Cleanup + await cleanup(); + }); + + test('user should be able to delete a label', async () => { + const api = await sharedUserClient(); + const [label, _] = await useLabel(api); + + const { response } = await api.labels.delete(label.id); + expect(response.status).toBe(204); + + // Ensure we can't get the label + const { response: getResponse } = await api.labels.get(label.id); + expect(getResponse.status).toBe(404); + }); +}); diff --git a/frontend/lib/api/__test__/user/locations.test.ts b/frontend/lib/api/__test__/user/locations.test.ts new file mode 100644 index 0000000..5b3285b --- /dev/null +++ b/frontend/lib/api/__test__/user/locations.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from 'vitest'; +import { Location } from '../../classes/locations'; +import { UserApi } from '../../user'; +import { sharedUserClient } from '../test-utils'; + +describe('locations lifecycle (create, update, delete)', () => { + let increment = 0; + + /** + * useLocatio sets up a location resource for testing, and returns a function + * that can be used to delete the location from the backend server. + */ + async function useLocation(api: UserApi): Promise<[Location, () => Promise]> { + const { response, data } = await api.locations.create({ + name: `__test__.location.name_${increment}`, + description: `__test__.location.description_${increment}`, + }); + expect(response.status).toBe(201); + increment++; + + const cleanup = async () => { + const { response } = await api.locations.delete(data.id); + expect(response.status).toBe(204); + }; + + return [data, cleanup]; + } + + test('user should be able to create a location', async () => { + const api = await sharedUserClient(); + + const locationData = { + name: 'test-location', + description: 'test-description', + }; + + const { response, data } = await api.locations.create(locationData); + + expect(response.status).toBe(201); + expect(data.id).toBeTruthy(); + + // Ensure we can get the location + const { response: getResponse, data: getData } = await api.locations.get(data.id); + + expect(getResponse.status).toBe(200); + expect(getData.id).toBe(data.id); + expect(getData.name).toBe(locationData.name); + expect(getData.description).toBe(locationData.description); + + // Cleanup + const { response: deleteResponse } = await api.locations.delete(data.id); + expect(deleteResponse.status).toBe(204); + }); + + test('user should be able to update a location', async () => { + const api = await sharedUserClient(); + const [location, cleanup] = await useLocation(api); + + const updateData = { + name: 'test-location-updated', + description: 'test-description-updated', + }; + + const { response } = await api.locations.update(location.id, updateData); + expect(response.status).toBe(200); + + // Ensure we can get the location + const { response: getResponse, data } = await api.locations.get(location.id); + expect(getResponse.status).toBe(200); + + expect(data.id).toBe(location.id); + expect(data.name).toBe(updateData.name); + expect(data.description).toBe(updateData.description); + + await cleanup(); + }); + + test('user should be able to delete a location', async () => { + const api = await sharedUserClient(); + const [location, _] = await useLocation(api); + + const { response } = await api.locations.delete(location.id); + expect(response.status).toBe(204); + + // Ensure we can't get the location + const { response: getResponse } = await api.locations.get(location.id); + expect(getResponse.status).toBe(404); + }); +}); diff --git a/frontend/lib/api/base/index.test.ts b/frontend/lib/api/base/index.test.ts index 2f40e0c..87401f7 100644 --- a/frontend/lib/api/base/index.test.ts +++ b/frontend/lib/api/base/index.test.ts @@ -1,24 +1,24 @@ import { describe, expect, it } from 'vitest'; -import { UrlBuilder } from '.'; +import { route } from '.'; describe('UrlBuilder', () => { - it('basic query parameter', () => { - const result = UrlBuilder('/test', { a: 'b' }); - expect(result).toBe('/api/v1/test?a=b'); - }); + it('basic query parameter', () => { + const result = route('/test', { a: 'b' }); + expect(result).toBe('/api/v1/test?a=b'); + }); - it('multiple query parameters', () => { - const result = UrlBuilder('/test', { a: 'b', c: 'd' }); - expect(result).toBe('/api/v1/test?a=b&c=d'); - }); + it('multiple query parameters', () => { + const result = route('/test', { a: 'b', c: 'd' }); + expect(result).toBe('/api/v1/test?a=b&c=d'); + }); - it('no query parameters', () => { - const result = UrlBuilder('/test'); - expect(result).toBe('/api/v1/test'); - }); + it('no query parameters', () => { + const result = route('/test'); + expect(result).toBe('/api/v1/test'); + }); - it('list-like query parameters', () => { - const result = UrlBuilder('/test', { a: ['b', 'c'] }); - expect(result).toBe('/api/v1/test?a=b&a=c'); - }); + it('list-like query parameters', () => { + const result = route('/test', { a: ['b', 'c'] }); + expect(result).toBe('/api/v1/test?a=b&a=c'); + }); }); diff --git a/frontend/lib/api/base/index.ts b/frontend/lib/api/base/index.ts index 12f6df5..58ddbae 100644 --- a/frontend/lib/api/base/index.ts +++ b/frontend/lib/api/base/index.ts @@ -1,2 +1,2 @@ export { BaseAPI } from './base-api'; -export { UrlBuilder } from './urls'; +export { route } from './urls'; diff --git a/frontend/lib/api/base/urls.ts b/frontend/lib/api/base/urls.ts index bb40883..4185383 100644 --- a/frontend/lib/api/base/urls.ts +++ b/frontend/lib/api/base/urls.ts @@ -3,14 +3,23 @@ const parts = { prefix: '/api/v1', }; -export function OverrideParts(host: string, prefix: string) { +export function overrideParts(host: string, prefix: string) { parts.host = host; parts.prefix = prefix; } export type QueryValue = string | string[] | number | number[] | boolean | null | undefined; -export function UrlBuilder(rest: string, params: Record = {}): string { +/** + * route is a the main URL builder for the API. It will use a predefined host and prefix (global) + * in the urls.ts file and then append the passed in path parameter uring the `URL` class from the + * browser. It will also append any query parameters passed in as the second parameter. + * + * The default host `http://localhost.com` is removed from the path if it is present. This allows us + * to bootstrap the API with different hosts as needed (like for testing) but still allows us to use + * relative URLs in pruduction because the API and client bundle are served from the same server/host. + */ +export function route(rest: string, params: Record = {}): string { const url = new URL(parts.prefix + rest, parts.host); for (const [key, value] of Object.entries(params)) { @@ -23,6 +32,5 @@ export function UrlBuilder(rest: string, params: Record = {} } } - // we return the path only, without the base URL return url.toString().replace('http://localhost.com', ''); } diff --git a/frontend/lib/api/classes/items.ts b/frontend/lib/api/classes/items.ts index cba979e..b6398ec 100644 --- a/frontend/lib/api/classes/items.ts +++ b/frontend/lib/api/classes/items.ts @@ -1,4 +1,4 @@ -import { BaseAPI, UrlBuilder } from '../base'; +import { BaseAPI, route } from '../base'; import { Label } from './labels'; import { Location } from './locations'; import { Results } from './types'; @@ -33,22 +33,22 @@ export interface Item { export class ItemsApi extends BaseAPI { async getAll() { - return this.http.get>(UrlBuilder('/items')); + return this.http.get>(route('/items')); } async create(item: ItemCreate) { - return this.http.post(UrlBuilder('/items'), item); + return this.http.post(route('/items'), item); } async get(id: string) { - return this.http.get(UrlBuilder(`/items/${id}`)); + return this.http.get(route(`/items/${id}`)); } async delete(id: string) { - return this.http.delete(UrlBuilder(`/items/${id}`)); + return this.http.delete(route(`/items/${id}`)); } async update(id: string, item: ItemCreate) { - return this.http.put(UrlBuilder(`/items/${id}`), item); + return this.http.put(route(`/items/${id}`), item); } } diff --git a/frontend/lib/api/classes/labels.ts b/frontend/lib/api/classes/labels.ts index 82aa005..32ab306 100644 --- a/frontend/lib/api/classes/labels.ts +++ b/frontend/lib/api/classes/labels.ts @@ -1,4 +1,4 @@ -import { BaseAPI, UrlBuilder } from '../base'; +import { BaseAPI, route } from '../base'; import { Details, OutType, Results } from './types'; export type LabelCreate = Details & { @@ -14,22 +14,22 @@ export type Label = LabelCreate & export class LabelsApi extends BaseAPI { async getAll() { - return this.http.get>(UrlBuilder('/labels')); + return this.http.get>(route('/labels')); } async create(label: LabelCreate) { - return this.http.post(UrlBuilder('/labels'), label); + return this.http.post(route('/labels'), label); } async get(id: string) { - return this.http.get