devices: added create and update endpoint
This commit is contained in:
parent
c8c8cc892d
commit
9eb6923398
|
@ -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
|
||||
}
|
||||
|
|
|
@ -3,19 +3,64 @@ 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
|
||||
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"`
|
||||
Devices models.DeviceSlice `json:"devices"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
|
@ -23,54 +68,14 @@ func (api *API) DevicesList(ctx context.Context, params DevicesListParams) (resu
|
|||
ctx, span := tracer.Start(ctx, "*API.DevicesList")
|
||||
defer span.End()
|
||||
|
||||
q := params.Query
|
||||
|
||||
if params.All {
|
||||
result.Devices, err = api.queries.DeviceGetAll(ctx)
|
||||
result.Devices, err = models.Devices.Query(ctx, api.exec, params.Query()...).All()
|
||||
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
|
||||
return result, errs.Wrapw(err, "failed to query devices", "params", params)
|
||||
}
|
||||
|
||||
if q != "" {
|
||||
like := "%" + q + "%"
|
||||
result.Devices, err = api.queries.DeviceSearch(ctx, queries.DeviceSearchParams{
|
||||
Name: like,
|
||||
Slug: like,
|
||||
Limit: params.Limit,
|
||||
Offset: params.Offset,
|
||||
})
|
||||
result.Total, err = models.Devices.Query(ctx, api.exec, params.CountQuery()...).Count()
|
||||
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,
|
||||
})
|
||||
if err != nil {
|
||||
return result, errs.Wrapw(err, "failed to list device", "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, errs.Wrapw(err, "failed to count devices", "params", params)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
|
|
28
api/devices_update.go
Normal file
28
api/devices_update.go
Normal file
|
@ -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
|
||||
}
|
|
@ -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 err.Error()
|
||||
}
|
||||
|
|
13
rest/devices/create.http
Normal file
13
rest/devices/create.http
Normal file
|
@ -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
|
||||
}
|
1
rest/devices/list.http
Normal file
1
rest/devices/list.http
Normal file
|
@ -0,0 +1 @@
|
|||
GET http://localhost:8080/api/v1/devices HTTP/1.1
|
9
rest/devices/update.http
Normal file
9
rest/devices/update.http
Normal file
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
70
server/routes/device_update.go
Normal file
70
server/routes/device_update.go
Normal file
|
@ -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"})
|
||||
}
|
|
@ -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) {
|
||||
|
|
Loading…
Reference in a new issue