api.spaceplanner.app

Spaceplanner API
git clone git://jacobedwards.org/api.spaceplanner.app
Log | Files | Refs

commit 8d98aaa42f272fe5873aa1059df14056627867bd
parent 8ccf324d76ea9df82b191ba61b76fc3ce016331f
Author: Jacob R. Edwards <jacob@jacobedwards.org>
Date:   Sun,  4 Aug 2024 21:47:08 -0700

Add some floorplan data and functions

Diffstat:
Acmd/api/floorplans.go | 191+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcmd/api/main.go | 12++++++++++++
Acmd/api/migration/2024-07-31T00:40:35.sql | 25+++++++++++++++++++++++++
Acmd/api/patch.go | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/backend/floorplan.go | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 439 insertions(+), 0 deletions(-)

diff --git a/cmd/api/floorplans.go b/cmd/api/floorplans.go @@ -0,0 +1,191 @@ +package main + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "jacobedwards.org/spaceplanner.app/internal/backend" +) + +type SettableFloorplan struct { + Name string `json:"name" binding:"required"` + Address string `json:"address"` + Synopsis string `json:"synopsis"` +} + +type Point struct { + X *int `json:"x" binding:"required"` + Y *int `json:"y" binding:"required"` +} + +func (e *Env) CreateFloorplan(c *gin.Context) { + var req SettableFloorplan + + user := c.Param("user") + if err := c.ShouldBind(&req); err != nil { + RespondError(c, 400, "%s", err.Error()) + return + } + + fp, err := e.backend.CreateFloorplan(nil, &backend.Floorplan{User: user, Name: req.Name, Address: req.Address, Synopsis: req.Synopsis}) + if err != nil { + RespondError(c, 400, "%s", err.Error()) + } else { + Respond(c, http.StatusOK, *fp) + } +} + +func (e *Env) UpdateFloorplan(c *gin.Context) { + user := c.Param("user") + name := c.Param("floorplan") + + patches := make([]Patch, 16) + if err := c.ShouldBind(&patches); err != nil { + RespondError(c, 400, "Unable to read patchset") + return + } + + tx, err := e.backend.DB.Begin() + if err != nil { + RespondError(c, 400, "Unable to begin transaction") + return + } + + fp, err := e.backend.GetFloorplan(tx, user, name) + if err != nil { + tx.Rollback() + RespondError(c, 400, "Unable to get floorplan") + return + } + + settable := toSettable(fp) + if err = applyPatchset(settable, patches); err != nil { + tx.Rollback() + RespondError(c, 400, "Unable to patch floorplan") + return + } + + newvals, err := fromSettable(user, settable) + if err != nil { + tx.Rollback() + RespondError(c, 400, "Unable to care anymore") + return + } + fp, err = e.backend.UpdateFloorplan(tx, user, name, newvals) + if err != nil { + tx.Rollback() + RespondError(c, 400, "Unable to push update") + return + } + + err = tx.Commit() + if err != nil { + RespondError(c, 400, "Unable to commit update") + } + Respond(c, 200, fp) +} + +func (e *Env) DeleteFloorplan(c *gin.Context) { + user := c.Param("user") + floorplan := c.Param("floorplan") + + fp, err := e.backend.DeleteFloorplan(nil, user, floorplan) + if err != nil { + RespondError(c, 400, "%s", err.Error()) + } else { + Respond(c, 200, fp) + } +} + +func (e *Env) GetFloorplans(c *gin.Context) { + user := c.Param("user") + + floorplans, err := e.backend.GetFloorplans(nil, user) + if err != nil { + RespondError(c, 400, "%s", err.Error()) + } else { + Respond(c, 200, floorplans) + } +} + +func (e *Env) GetFloorplan(c *gin.Context) { + user := c.Param("user") + name := c.Param("floorplan") + + fp, err := e.backend.GetFloorplan(nil, user, name) + if err != nil { + RespondError(c, 400, "%s", err.Error()) + } else { + Respond(c, 200, fp) + } +} + +func (e *Env) AddFloorplanPoints(c *gin.Context) { + user := c.Param("user") + floorplan := c.Param("floorplan") + + var points []Point + if err := c.ShouldBind(&points); err != nil { + RespondError(c, 400, "%s: Unable to read points", err.Error()) + return + } + + tx, err := e.backend.DB.Begin() + if err != nil { + RespondError(c, 400, "Unable to begin transaction") + return + } + + var rpoints []backend.Point + for _, point := range points { + rp, err := e.backend.AddFloorplanPoint(tx, user, floorplan, backend.Point{ X: *point.X, Y: *point.Y }) + if err != nil { + tx.Rollback() + RespondError(c, 400, "%s: Unable to add point", err.Error()) + return + } + rpoints = append(rpoints, rp) + } + if err := tx.Commit(); err != nil { + RespondError(c, 400, "Unable to commit") + } else { + Respond(c, 200, rpoints) + } +} + +func (e *Env) GetFloorplanPoints(c *gin.Context) { + user := c.Param("user") + floorplan := c.Param("floorplan") + + points, err := e.backend.GetFloorplanPoints(nil, user, floorplan) + if err != nil { + RespondError(c, 400, "Unable to get points") + } else { + Respond(c, 200, points) + } +} + +func fromSettable(user string, data map[string]interface{}) (*backend.Floorplan, error) { + for k := range data { + switch data[k].(type) { + case string: + default: + return nil, errors.New("Invalid Type") + } + } + return &backend.Floorplan{ + User: user, + Name: data["name"].(string), + Address: data["address"].(string), + Synopsis: data["synopsis"].(string), + }, nil +} + +func toSettable(f *backend.Floorplan) map[string]interface{} { + return map[string](interface{}){ + "name": f.Name, + "address": f.Address, + "synopsis": f.Synopsis, + } +} diff --git a/cmd/api/main.go b/cmd/api/main.go @@ -82,6 +82,18 @@ func setAuthenticatedRoutes(env *Env, r *gin.RouterGroup) { user.GET("/settings", env.GetUserSettings) user.GET("/settings/:setting", env.GetUserSetting) + fp := r.Group("/floorplans/:user") + fp.GET("", env.GetFloorplans) + fp.POST("", env.CreateFloorplan) + + fp = fp.Group("/:floorplan") + fp.PATCH("", env.UpdateFloorplan) + fp.DELETE("", env.DeleteFloorplan) + fp.GET("", env.GetFloorplan) + + pt := fp.Group("/points") + pt.GET("", env.GetFloorplanPoints) + pt.POST("", env.AddFloorplanPoints) } func noRoute(c *gin.Context) { diff --git a/cmd/api/migration/2024-07-31T00:40:35.sql b/cmd/api/migration/2024-07-31T00:40:35.sql @@ -0,0 +1,25 @@ +CREATE TABLE spaceplanner.floorplans ( + id bigserial PRIMARY KEY, + owner varchar REFERENCES users(name), + name varchar, + address varchar, + synopsis varchar, + updated timestamp DEFAULT now(), + created timestamp DEFAULT now(), + CONSTRAINT id UNIQUE (owner, name) +); + +CREATE TABLE spaceplanner.floorplan_points ( + floorplan int REFERENCES spaceplanner.floorplans(id), + id int, + x int, + y int, + PRIMARY KEY (floorplan, id) +); + +CREATE FUNCTION spaceplanner.floorplan_id (varchar, varchar) + RETURNS bigint + LANGUAGE sql + STABLE + RETURNS NULL ON NULL INPUT + RETURN (SELECT id FROM floorplans WHERE owner = $1 AND name = $2) ; diff --git a/cmd/api/patch.go b/cmd/api/patch.go @@ -0,0 +1,58 @@ +package main + +import "errors" + +type Patch struct { + Op string `json:"op" binding:"required"` + Path string `json:"path" binding:"required"` + From string `json:"from"` + Value interface{} `json:"value" binding:"required"` +} + +func applyPatchset(data map[string]interface{}, patches []Patch) error { + for _, patch := range patches { + if err := applyPatch(data, patch); err != nil { + return err + } + } + return nil +} + +func applyPatch(data map[string]interface{}, p Patch) error { + // I'm aware path's are suppost to be able to be of any depth. Later. + v, exists := data[p.Path] + switch p.Op { + case "test": + if v != p.Value { + return errors.New("test failed") + } + case "remove": + if !exists { + return errors.New("Cannot remove non-existent") + } + data[p.Path] = nil + case "add": + data[p.Path] = p.Value + case "replace": + if !exists { + return errors.New("Cannot replace non-existent") + } + data[p.Path] = p.Value + case "move": + _, fexists := data[p.From] + if !fexists { + return errors.New("From does not exist") + } + data[p.Path] = data[p.From] + data[p.From] = nil + case "copy": + fv, fexists := data[p.From] + if !fexists { + return errors.New("From does not exist") + } + data[p.Path] = fv + default: + return errors.New(p.Op + ": Invalid operation") + } + return nil +} diff --git a/internal/backend/floorplan.go b/internal/backend/floorplan.go @@ -0,0 +1,152 @@ +package backend + +import ( + "database/sql" + "time" +) + +type Floorplan struct { + id int + User string `json:"user"` + Name string `json:"name"` + Address string `json:"address"` + Synopsis string `json:"synopsis"` + Updated time.Time `json:"updated"` + Created time.Time `json:"created"` +} + +type Point struct { + //floorplan_id int + Id int `json:"id"` + X int `json:"x"` + Y int `json:"y"` +} + +type Scanner interface { + Scan(dest ...any) error +} + +func (e *Env) CreateFloorplan(tx *sql.Tx, template *Floorplan) (*Floorplan, error) { + stmt, err := e.CacheTxStmt(tx, "create_floorplan", `INSERT INTO spaceplanner.floorplans (owner, name, address, synopsis) + VALUES ($1, $2, $3, $4) RETURNING *`) + if err != nil { + return nil, err + } + + return scanFloorplan(stmt.QueryRow(template.User, template.Name, template.Address, template.Synopsis)) +} + +func (e *Env) UpdateFloorplan(tx *sql.Tx, user string, name string, updated *Floorplan) (*Floorplan, error) { + stmt, err := e.CacheTxStmt(tx, "create_floorplan", `UPDATE spaceplanner.floorplans SET (name, address, synopsis) = + ($3, $4, $5) WHERE owner = $1 AND name = $2 RETURNING *`) + if err != nil { + return nil, err + } + + return scanFloorplan(stmt.QueryRow(user, name, updated.Name, updated.Address, updated.Synopsis)) +} + +func (e *Env) GetFloorplan(tx *sql.Tx, user string, name string) (*Floorplan, error) { + stmt, err := e.CacheTxStmt(tx, "get_floorplan", "SELECT * FROM spaceplanner.floorplans WHERE owner = $1 AND name = $2") + if err != nil { + return nil, err + } + + return scanFloorplan(stmt.QueryRow(user, name)) +} + +func (e *Env) GetFloorplans(tx *sql.Tx, user string) ([]Floorplan, error) { + stmt, err := e.CacheTxStmt(tx, "get_floorplans", "SELECT * FROM spaceplanner.floorplans WHERE owner = $1") + if err != nil { + return nil, err + } + + var floorplans []Floorplan + rows, err := stmt.Query(user) + if err != nil { + return nil, err + } + for rows.Next() { + t, err := scanFloorplan(rows) + if err != nil { + return nil, err + } + floorplans = append(floorplans, *t) + } + return floorplans, nil +} + +func (e *Env) DeleteFloorplan(tx *sql.Tx, user string, name string) (*Floorplan, error) { + stmt, err := e.CacheTxStmt(tx, "del_floorplan", "DELETE FROM spaceplanner.floorplans WHERE owner = $1 AND name = $2 RETURNING *") + if err != nil { + return nil, err + } + + return scanFloorplan(stmt.QueryRow(user, name)) +} + +func (e *Env) AddFloorplanPoint(tx *sql.Tx, user string, floorplan string, point Point) (Point, error) { + stmt, err := e.CacheTxStmt(tx, "add_point", `INSERT INTO floorplan_points VALUES ( + spaceplanner.floorplan_id($1, $2), (SELECT coalesce(max(id), 0) FROM + floorplan_points WHERE floorplan = spaceplanner.floorplan_id($1, $2)) + 1, $3, $4) RETURNING id, x, y`) + if err != nil { + return Point{}, err + } + + row := stmt.QueryRow(user, floorplan, point.X, point.Y) + return scanPoint(row) + +} + +func (e *Env) InsertFloorplanPoint(tx *sql.Tx, user string, floorplan string, point Point) error { + stmt, err := e.CacheTxStmt(tx, "ins_point", `WITH t AS ( + UPDATE floorplan_points + WHERE floorplan = spaceplanner.floorplan_id($1, $2) AND id > $3 SET id = id + 1 + ) INSERT INTO floorplan_points id, x, y VALUES (spaceplanner.floorplan_id($1, $2), $3, $4, $5)`) + + if err != nil { + return err + } + _, err = stmt.Exec(user, floorplan, point.Id, point.X, point.Y) + return err +} + +func (e *Env) GetFloorplanPoints(tx *sql.Tx, user string, floorplan string) ([]Point, error) { + stmt, err := e.CacheTxStmt(tx, "get_points", "SELECT id, x, y FROM floorplan_points WHERE floorplan = spaceplanner.floorplan_id($1, $2)") + if err != nil { + return nil, err + } + + rows, err := stmt.Query(user, floorplan) + if err != nil { + return nil, err + } + return collectPoints(rows) +} + +func scanFloorplan(row Scanner) (*Floorplan, error) { + var f Floorplan + err := row.Scan(&f.id, &f.User, &f.Name, &f.Address, &f.Synopsis, &f.Updated, &f.Created) + return &f, err +} + +func collectPoints(rows *sql.Rows) ([]Point, error) { + var points []Point + + for rows.Next() { + point, err := scanPoint(rows) + if err != nil { + return nil, err + } + points = append(points, point) + } + + return points, nil +} + +func scanPoint(row Scanner) (Point, error) { + var point Point + + err := row.Scan(&point.Id, &point.X, &point.Y) + return point, err +} +\ No newline at end of file