devices: added create and update endpoint

This commit is contained in:
Tigor Hutasuhut 2024-04-24 21:57:13 +07:00
parent c8c8cc892d
commit 9eb6923398
11 changed files with 268 additions and 67 deletions

View file

@ -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
}

View file

@ -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

28
api/devices_update.go Normal file
View 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
}

View file

@ -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()
}

13
rest/devices/create.http Normal file
View 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
View file

@ -0,0 +1 @@
GET http://localhost:8080/api/v1/devices HTTP/1.1

9
rest/devices/update.http Normal file
View 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
}

View file

@ -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
}

View file

@ -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
}

View 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"})
}

View file

@ -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) {