diff --git a/api/devices_create.go b/api/devices_create.go index 6ee2b65..ff02bb0 100644 --- a/api/devices_create.go +++ b/api/devices_create.go @@ -2,14 +2,28 @@ package api import ( "context" + "errors" - "github.com/tigorlazuardi/redmage/db/queries" + "github.com/mattn/go-sqlite3" + "github.com/tigorlazuardi/redmage/models" + "github.com/tigorlazuardi/redmage/pkg/errs" ) -type DeviceCreateParams = queries.DeviceCreateParams +type DeviceCreateParams = models.DeviceSetter -func (api *API) DevicesCreate(ctx context.Context, params DeviceCreateParams) (queries.Device, error) { +func (api *API) DevicesCreate(ctx context.Context, params *DeviceCreateParams) (*models.Device, error) { ctx, span := tracer.Start(ctx, "*API.DevicesCreate") defer span.End() - return api.queries.DeviceCreate(ctx, params) + + device, err := models.Devices.Insert(ctx, api.exec, params) + if err != nil { + var sqliteErr sqlite3.Error + if errors.As(err, &sqliteErr) { + if sqliteErr.Code == sqlite3.ErrConstraint { + return nil, errs.Wrapw(sqliteErr, "device already exists", "params", params).Code(409) + } + } + return nil, errs.Wrapw(err, "failed to create device", "params", params) + } + return device, nil } diff --git a/api/devices_list.go b/api/devices_list.go index acc4832..42eac4b 100644 --- a/api/devices_list.go +++ b/api/devices_list.go @@ -3,74 +3,79 @@ package api import ( "context" - "github.com/tigorlazuardi/redmage/db/queries" + "github.com/stephenafamo/bob" + "github.com/stephenafamo/bob/dialect/sqlite" + "github.com/stephenafamo/bob/dialect/sqlite/dialect" + "github.com/stephenafamo/bob/dialect/sqlite/sm" + "github.com/tigorlazuardi/redmage/models" "github.com/tigorlazuardi/redmage/pkg/errs" ) type DevicesListParams struct { - All bool - Query string - Limit int64 - Offset int64 + All bool + Q string + Limit int64 + Offset int64 + OrderBy string + Sort string +} + +func (dlp DevicesListParams) Query() (expr []bob.Mod[*dialect.SelectQuery]) { + expr = append(expr, dlp.CountQuery()...) + if dlp.All { + return expr + } + + if dlp.Limit > 0 { + expr = append(expr, sm.Limit(dlp.Limit)) + } + + if dlp.Offset > 0 { + expr = append(expr, sm.Offset(dlp.Offset)) + } + + if dlp.OrderBy != "" { + order := sm.OrderBy(sqlite.Quote(dlp.OrderBy)) + if dlp.Sort == "desc" { + expr = append(expr, order.Desc()) + } else { + expr = append(expr, order.Asc()) + } + } + + return expr +} + +func (dlp DevicesListParams) CountQuery() (expr []bob.Mod[*dialect.SelectQuery]) { + if dlp.Q != "" { + arg := sqlite.Arg("%" + dlp.Q + "%") + expr = append(expr, + sm.Where( + models.DeviceColumns.Name.Like(arg).Or(models.DeviceColumns.Slug.Like(arg)), + ), + ) + } + + return expr } type DevicesListResult struct { - Devices []queries.Device `json:"devices"` - Total int64 `json:"total"` + Devices models.DeviceSlice `json:"devices"` + Total int64 `json:"total"` } func (api *API) DevicesList(ctx context.Context, params DevicesListParams) (result DevicesListResult, err error) { ctx, span := tracer.Start(ctx, "*API.DevicesList") defer span.End() - q := params.Query - - if params.All { - result.Devices, err = api.queries.DeviceGetAll(ctx) - if err != nil { - return result, errs.Wrapw(err, "failed to get all devices", "params", params) - } - result.Total, err = api.queries.DeviceCount(ctx) - if err != nil { - return result, errs.Wrapw(err, "failed to count all devices", "params", params) - } - return result, nil - } - - if q != "" { - like := "%" + q + "%" - result.Devices, err = api.queries.DeviceSearch(ctx, queries.DeviceSearchParams{ - Name: like, - Slug: like, - Limit: params.Limit, - Offset: params.Offset, - }) - if err != nil { - return result, errs.Wrapw(err, "failed to search device", "params", params) - } - result.Total, err = api.queries.DeviceSearchCount(ctx, queries.DeviceSearchCountParams{ - Name: like, - Slug: like, - Limit: params.Limit, - Offset: params.Offset, - }) - if err != nil { - return result, errs.Wrapw(err, "failed to count device search", "params", params) - } - return result, nil - } - - result.Devices, err = api.queries.DeviceList(ctx, queries.DeviceListParams{ - Limit: params.Limit, - Offset: params.Offset, - }) + result.Devices, err = models.Devices.Query(ctx, api.exec, params.Query()...).All() if err != nil { - return result, errs.Wrapw(err, "failed to list device", "params", params) + return result, errs.Wrapw(err, "failed to query devices", "params", params) } - result.Total, err = api.queries.DeviceCount(ctx) + result.Total, err = models.Devices.Query(ctx, api.exec, params.CountQuery()...).Count() if err != nil { - return result, errs.Wrapw(err, "failed to count all devices", "params", params) + return result, errs.Wrapw(err, "failed to count devices", "params", params) } return result, nil diff --git a/api/devices_update.go b/api/devices_update.go new file mode 100644 index 0000000..fd38980 --- /dev/null +++ b/api/devices_update.go @@ -0,0 +1,28 @@ +package api + +import ( + "context" + "errors" + + "github.com/mattn/go-sqlite3" + "github.com/tigorlazuardi/redmage/models" + "github.com/tigorlazuardi/redmage/pkg/errs" +) + +func (api *API) DevicesUpdate(ctx context.Context, id int, update *models.DeviceSetter) (err error) { + ctx, span := tracer.Start(ctx, "*API.DevicesUpdate") + defer span.End() + + err = models.Devices.Update(ctx, api.exec, update, &models.Device{ID: int32(id)}) + if err != nil { + var sqliteErr sqlite3.Error + if errors.As(err, &sqliteErr) { + if sqliteErr.Code == sqlite3.ErrNo(sqlite3.ErrConstraintUnique) { + return errs.Wrapw(err, "a device with the same slug id already exists").Code(409) + } + } + return errs.Wrapw(err, "failed to update device", "id", id, "values", update) + } + + return +} diff --git a/pkg/errs/query.go b/pkg/errs/query.go index 6a665c6..b768aea 100644 --- a/pkg/errs/query.go +++ b/pkg/errs/query.go @@ -3,13 +3,30 @@ package errs import "errors" func FindCodeOrDefault(err error, def int) int { - for unwrap := errors.Unwrap(err); unwrap != nil; err = unwrap { + unwrap := errors.Unwrap(err) + for unwrap != nil { if coder, ok := err.(interface{ GetCode() int }); ok { code := coder.GetCode() if code != 0 { - def = code + return code + } + } + unwrap = errors.Unwrap(unwrap) + } + + return def +} + +func FindMessage(err error) string { + unwrap := errors.Unwrap(err) + for unwrap != nil { + if messager, ok := err.(interface{ GetMessage() string }); ok { + message := messager.GetMessage() + if message != "" { + return message } } } - return def + + return err.Error() } diff --git a/rest/devices/create.http b/rest/devices/create.http new file mode 100644 index 0000000..dd39dac --- /dev/null +++ b/rest/devices/create.http @@ -0,0 +1,13 @@ +POST http://localhost:8080/api/v1/devices HTTP/1.1 +Host: localhost:8080 +Content-Type: application/json +Content-Length: 155 + +{ + "name": "Laptop", + "slug": "laptop", + "resolution_x": 1920, + "resolution_y": 1080, + "nsfw": 1, + "aspect_ratio_tolerance": 0.2 +} diff --git a/rest/devices/list.http b/rest/devices/list.http new file mode 100644 index 0000000..f11b070 --- /dev/null +++ b/rest/devices/list.http @@ -0,0 +1 @@ +GET http://localhost:8080/api/v1/devices HTTP/1.1 diff --git a/rest/devices/update.http b/rest/devices/update.http new file mode 100644 index 0000000..1ff95f9 --- /dev/null +++ b/rest/devices/update.http @@ -0,0 +1,9 @@ +PATCH http://localhost:8080/api/v1/devices/1 HTTP/1.1 +Host: localhost:8080 +Content-Type: application/json +Content-Length: 55 + +{ + "aspect_ratio_tolerance": 0.2, + "nsfw": 1 +} diff --git a/server/routes/device_create.go b/server/routes/device_create.go index 650686c..535a63e 100644 --- a/server/routes/device_create.go +++ b/server/routes/device_create.go @@ -5,8 +5,11 @@ import ( "errors" "fmt" "net/http" + "regexp" - "github.com/tigorlazuardi/redmage/api" + "github.com/aarondl/opt/omit" + "github.com/tigorlazuardi/redmage/models" + "github.com/tigorlazuardi/redmage/pkg/errs" "github.com/tigorlazuardi/redmage/pkg/log" "github.com/tigorlazuardi/redmage/pkg/telemetry" ) @@ -16,7 +19,7 @@ func (routes *Routes) APIDeviceCreate(rw http.ResponseWriter, r *http.Request) { ctx, span := tracer.Start(r.Context(), "*Routes.APIDeviceCreate") defer func() { telemetry.EndWithStatus(span, err) }() - var body api.DeviceCreateParams + var body models.Device if err = json.NewDecoder(r.Body).Decode(&body); err != nil { log.New(ctx).Err(err).Error("failed to decode json body") @@ -25,37 +28,58 @@ func (routes *Routes) APIDeviceCreate(rw http.ResponseWriter, r *http.Request) { return } - if err = validateDeviceCreateParams(body); err != nil { + if err = validateCreateDevice(body); err != nil { rw.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(rw).Encode(map[string]string{"error": err.Error()}) return } - device, err := routes.API.DevicesCreate(ctx, body) + device, err := routes.API.DevicesCreate(ctx, &models.DeviceSetter{ + Slug: omit.From(body.Slug), + Name: omit.From(body.Name), + ResolutionX: omit.From(body.ResolutionX), + ResolutionY: omit.From(body.ResolutionY), + AspectRatioTolerance: omit.From(body.AspectRatioTolerance), + MinX: omit.From(body.MinX), + MinY: omit.From(body.MinY), + MaxX: omit.From(body.MaxX), + MaxY: omit.From(body.MaxY), + NSFW: omit.From(body.NSFW), + WindowsWallpaperMode: omit.From(body.WindowsWallpaperMode), + }) if err != nil { - rw.WriteHeader(http.StatusInternalServerError) - _ = json.NewEncoder(rw).Encode(map[string]string{"error": err.Error()}) + rw.WriteHeader(errs.FindCodeOrDefault(err, http.StatusInternalServerError)) + _ = json.NewEncoder(rw).Encode(map[string]string{"error": errs.FindMessage(err)}) return } + rw.WriteHeader(http.StatusCreated) if err := json.NewEncoder(rw).Encode(device); err != nil { log.New(ctx).Err(err).Error("failed to marshal json api devices") } } -func validateDeviceCreateParams(params api.DeviceCreateParams) error { +var slugRegex = regexp.MustCompile(`^[a-z0-9-]+$`) + +func validateCreateDevice(params models.Device) error { if params.Name == "" { return errors.New("name is required") } if params.Slug == "" { return errors.New("slug is required") } + if !slugRegex.MatchString(params.Slug) { + return errors.New("slug must be lowercase alphanumeric with dash. eg: my-awesome-laptop") + } if params.ResolutionX < 1 { return errors.New("device width resolution is required") } if params.ResolutionY < 1 { return errors.New("device height resolution is required") } + if params.AspectRatioTolerance < 0 { + return errors.New("aspect ratio tolerance cannot be negative value") + } if params.MaxX < 0 { params.MaxX = 0 } @@ -68,5 +92,17 @@ func validateDeviceCreateParams(params api.DeviceCreateParams) error { if params.MinY < 0 { params.MinY = 0 } + if params.NSFW < 0 { + params.NSFW = 0 + } + if params.NSFW > 1 { + params.NSFW = 1 + } + if params.WindowsWallpaperMode < 0 { + params.WindowsWallpaperMode = 0 + } + if params.WindowsWallpaperMode > 1 { + params.WindowsWallpaperMode = 1 + } return nil } diff --git a/server/routes/device_list.go b/server/routes/device_list.go index 3756949..8e7513d 100644 --- a/server/routes/device_list.go +++ b/server/routes/device_list.go @@ -31,11 +31,17 @@ func parseApiDeviceListQueries(req *http.Request) (params api.DevicesListParams) params.All, _ = strconv.ParseBool(req.FormValue("all")) params.Offset, _ = strconv.ParseInt(req.FormValue("offset"), 10, 64) params.Limit, _ = strconv.ParseInt(req.FormValue("limit"), 10, 64) - params.Query = req.FormValue("q") + params.Q = req.FormValue("q") + params.OrderBy = req.FormValue("order") + params.Sort = req.FormValue("sort") if params.Limit < 1 { params.Limit = 10 } + if params.OrderBy == "" { + params.OrderBy = "name" + } + return params } diff --git a/server/routes/device_update.go b/server/routes/device_update.go new file mode 100644 index 0000000..5d6d0d4 --- /dev/null +++ b/server/routes/device_update.go @@ -0,0 +1,70 @@ +package routes + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/aarondl/opt/omit" + "github.com/go-chi/chi/v5" + "github.com/tigorlazuardi/redmage/models" + "github.com/tigorlazuardi/redmage/pkg/errs" + "github.com/tigorlazuardi/redmage/pkg/log" +) + +type deviceUpdate struct { + models.Device + + MinX *int32 `json:"min_x"` + MinY *int32 `json:"min_y"` + MaxX *int32 `json:"max_x"` + MaxY *int32 `json:"max_y"` + NSFW *int32 `json:"nsfw"` + WindowsWallpaperMode *int32 `json:"windows_wallpaper_mode"` + AspectRatioTolerance *float64 `json:"aspect_ratio_tolerance"` +} + +func (routes *Routes) APIDeviceUpdate(rw http.ResponseWriter, r *http.Request) { + var err error + ctx, span := tracer.Start(r.Context(), "*Routes.APIDeviceUpdate") + defer span.End() + + id, err := strconv.Atoi(chi.URLParam(r, "id")) + if err != nil { + log.New(ctx).Err(err).Error("failed to parse id") + rw.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(rw).Encode(map[string]string{"error": fmt.Sprintf("bad id: %s", err)}) + return + } + + var body deviceUpdate + + if err = json.NewDecoder(r.Body).Decode(&body); err != nil { + log.New(ctx).Err(err).Error("failed to decode json body") + rw.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(rw).Encode(map[string]string{"error": fmt.Sprintf("cannot decode json body: %s", err)}) + return + } + + err = routes.API.DevicesUpdate(ctx, id, &models.DeviceSetter{ + Slug: omit.FromCond(body.Slug, body.Slug != ""), + Name: omit.FromCond(body.Name, body.Name != ""), + ResolutionX: omit.FromCond(body.ResolutionX, body.ResolutionX != 0), + ResolutionY: omit.FromCond(body.ResolutionY, body.ResolutionY != 0), + AspectRatioTolerance: omit.FromPtr(body.AspectRatioTolerance), + MinX: omit.FromPtr(body.MinX), + MinY: omit.FromPtr(body.MinY), + MaxX: omit.FromPtr(body.MaxX), + MaxY: omit.FromPtr(body.MaxY), + NSFW: omit.FromPtr(body.NSFW), + WindowsWallpaperMode: omit.FromPtr(body.WindowsWallpaperMode), + }) + if err != nil { + rw.WriteHeader(errs.FindCodeOrDefault(err, http.StatusInternalServerError)) + _ = json.NewEncoder(rw).Encode(map[string]string{"error": errs.FindMessage(err)}) + return + } + + _ = json.NewEncoder(rw).Encode(map[string]string{"message": "ok"}) +} diff --git a/server/routes/routes.go b/server/routes/routes.go index d782e57..ada2021 100644 --- a/server/routes/routes.go +++ b/server/routes/routes.go @@ -19,6 +19,8 @@ type Routes struct { } func (routes *Routes) Register(router chi.Router) { + router.Use(chimiddleware.Compress(5, "text/html", "text/css", "application/javascript", "application/json")) + router.HandleFunc("/ping", routes.HealthCheck) router.HandleFunc("/health", routes.HealthCheck) if routes.Config.Bool("http.hotreload") { @@ -37,10 +39,10 @@ func (routes *Routes) registerV1APIRoutes(router chi.Router) { router.Get("/subreddits", routes.SubredditsListAPI) router.Get("/devices", routes.APIDeviceList) router.Post("/devices", routes.APIDeviceCreate) + router.Patch("/devices/{id}", routes.APIDeviceUpdate) } func (routes *Routes) registerWWWRoutes(router chi.Router) { - router.Use(chimiddleware.Compress(5, "text/html", "text/css", "application/javascript")) router.Mount("/public", http.StripPrefix("/public", http.FileServer(http.FS(routes.PublicDir)))) router.Group(func(r chi.Router) {