add automation CLI and sync API support
This commit is contained in:
1 parent
da63ee8f7c
commit
35ed58d6c4
13 files changed
+1323
-42
No files matched your search
@@ -17,5 +17,6 @@
|
||||
local.properties
|
||||
/server/data/
|
||||
/server/box-manifest-server
|
||||
/server/box-manifest
|
||||
/web/node_modules/
|
||||
/web/dist/
|
||||
@@ -8,6 +8,17 @@ info:
|
||||
servers:
|
||||
- url: /
|
||||
paths:
|
||||
/api/system/info:
|
||||
get:
|
||||
operationId: getSystemInfo
|
||||
summary: Identify this server installation and API generation
|
||||
responses:
|
||||
"200":
|
||||
description: Stable server installation information
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/SystemInfo" }
|
||||
default: { $ref: "#/components/responses/Error" }
|
||||
/api/node-types:
|
||||
get:
|
||||
operationId: listNodeTypes
|
||||
@@ -208,6 +219,20 @@ paths:
|
||||
$ref: "#/components/schemas/CreateNodesBulkResponse"
|
||||
default:
|
||||
$ref: "#/components/responses/Error"
|
||||
patch:
|
||||
operationId: updateNodesBulk
|
||||
summary: Replace the description on multiple nodes atomically
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UpdateNodesBulkRequest"
|
||||
responses:
|
||||
"204":
|
||||
description: Descriptions updated
|
||||
default:
|
||||
$ref: "#/components/responses/Error"
|
||||
/api/nodes/bulk/move:
|
||||
post:
|
||||
operationId: moveNodesBulk
|
||||
@@ -429,6 +454,14 @@ components:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
schemas:
|
||||
SystemInfo:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [serverId, name, apiVersion]
|
||||
properties:
|
||||
serverId: { type: string, minLength: 1 }
|
||||
name: { type: string, minLength: 1 }
|
||||
apiVersion: { type: integer, minimum: 1 }
|
||||
Node:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
@@ -441,6 +474,7 @@ components:
|
||||
- quantity
|
||||
- typeId
|
||||
- tagIds
|
||||
- revision
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
@@ -471,6 +505,9 @@ components:
|
||||
type: array
|
||||
uniqueItems: true
|
||||
items: { type: string }
|
||||
revision:
|
||||
type: integer
|
||||
minimum: 1
|
||||
ImportNode:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
@@ -575,6 +612,7 @@ components:
|
||||
- quantity
|
||||
- typeId
|
||||
- tagIds
|
||||
- revision
|
||||
- children
|
||||
properties:
|
||||
id:
|
||||
@@ -605,6 +643,9 @@ components:
|
||||
type: array
|
||||
uniqueItems: true
|
||||
items: { type: string }
|
||||
revision:
|
||||
type: integer
|
||||
minimum: 1
|
||||
children:
|
||||
type: array
|
||||
items:
|
||||
@@ -615,6 +656,10 @@ components:
|
||||
required:
|
||||
- name
|
||||
properties:
|
||||
clientId:
|
||||
type: string
|
||||
pattern: "^[0-9a-f]{32}$"
|
||||
description: Optional client-generated identity used to make offline create retries idempotent.
|
||||
parentId:
|
||||
type: string
|
||||
description: Omit to place the node beneath Unsorted.
|
||||
@@ -703,6 +748,23 @@ components:
|
||||
uniqueItems: true
|
||||
items:
|
||||
type: string
|
||||
UpdateNodesBulkRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- nodeIds
|
||||
- description
|
||||
properties:
|
||||
nodeIds:
|
||||
type: array
|
||||
minItems: 1
|
||||
uniqueItems: true
|
||||
items:
|
||||
type: string
|
||||
description:
|
||||
type:
|
||||
- string
|
||||
- "null"
|
||||
IndividualizeNodeRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
@@ -753,6 +815,10 @@ components:
|
||||
- integer
|
||||
- "null"
|
||||
minimum: 0
|
||||
baseRevision:
|
||||
type: integer
|
||||
minimum: 1
|
||||
description: Reject the update with REVISION_CONFLICT unless the node still has this revision.
|
||||
NodeType:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
# Box Manifest CLI
|
||||
|
||||
The CLI is a JSON-first client for the Box Manifest HTTP API. It is intended
|
||||
for interactive use, shell automation, and AI agents. It performs no direct
|
||||
database access.
|
||||
|
||||
Build it from the repository root:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
go build -o box-manifest ./cmd/box-manifest
|
||||
```
|
||||
|
||||
The default server is `http://box-manifest.hosts.uuard.com:8080`. A different
|
||||
server can be selected with `BOX_MANIFEST_SERVER_URL` or a global flag placed
|
||||
before the command:
|
||||
|
||||
```bash
|
||||
box-manifest --server http://localhost:8080 server show
|
||||
```
|
||||
|
||||
Successful results are JSON on standard output. Failures are JSON on standard
|
||||
error and return a nonzero exit status. Add `--compact` before the command for
|
||||
single-line output.
|
||||
|
||||
## Read and search
|
||||
|
||||
```bash
|
||||
box-manifest server show
|
||||
box-manifest tree
|
||||
box-manifest search "blue bin"
|
||||
box-manifest node get --id NODE_ID
|
||||
box-manifest node get --code LOOKUP_CODE
|
||||
```
|
||||
|
||||
Search matches names, descriptions, and exact lookup codes. Results contain a
|
||||
node ID and its complete path.
|
||||
|
||||
## Nodes
|
||||
|
||||
```bash
|
||||
box-manifest node create --parent PARENT_ID --name NAME \
|
||||
[--description TEXT] [--quantity N]
|
||||
|
||||
box-manifest node update --id NODE_ID [--name NAME] \
|
||||
[--description TEXT | --clear-description] \
|
||||
[--quantity N | --clear-quantity]
|
||||
|
||||
box-manifest node move --id NODE_ID --parent PARENT_ID \
|
||||
[--children WITH_SUBTREE|PROMOTE_CHILDREN|CHILDREN_TO_UNSORTED]
|
||||
|
||||
box-manifest node delete --id NODE_ID
|
||||
box-manifest node individualize --id NODE_ID --names NAME,NAME,...
|
||||
box-manifest node combine --ids ID,ID,... --retain ID --name NAME
|
||||
box-manifest node classify --id NODE_ID [--type TYPE_ID|--clear-type] \
|
||||
[--tags TAG_ID,TAG_ID,...]
|
||||
```
|
||||
|
||||
Classification preserves the current type or tags when the corresponding flag
|
||||
is omitted. Pass an empty `--tags=` value to remove every tag.
|
||||
|
||||
## Bulk operations
|
||||
|
||||
```bash
|
||||
box-manifest node bulk-create --parent ID --names NAME,NAME,... \
|
||||
[--description TEXT]
|
||||
box-manifest node bulk-move --ids ID,ID,... --parent ID [--children MODE]
|
||||
box-manifest node bulk-delete --ids ID,ID,...
|
||||
box-manifest node bulk-classify --ids ID,ID,... \
|
||||
[--set-type --type TYPE_ID] [--add-tags ID,ID] [--remove-tags ID,ID]
|
||||
```
|
||||
|
||||
Use `--set-type` without `--type` to clear the type from every selected node.
|
||||
|
||||
## Types and tags
|
||||
|
||||
```bash
|
||||
box-manifest type list
|
||||
box-manifest type create --name NAME [--description TEXT] [--icon KEY] [--color HEX]
|
||||
box-manifest type update --id ID --name NAME [--description TEXT] [--icon KEY] [--color HEX]
|
||||
box-manifest type delete --id ID
|
||||
|
||||
box-manifest tag list
|
||||
box-manifest tag create --name NAME [--color HEX]
|
||||
box-manifest tag update --id ID --name NAME [--color HEX]
|
||||
box-manifest tag delete --id ID
|
||||
```
|
||||
|
||||
## Import and export
|
||||
|
||||
Import remains a deliberate two-step operation:
|
||||
|
||||
```bash
|
||||
box-manifest import preview --parent PARENT_ID --file manifest.json
|
||||
box-manifest import commit --plan PLAN_ID
|
||||
```
|
||||
|
||||
Use `--file -` or omit `--file` to read a manifest from standard input. Export
|
||||
produces the same portable, UUID-free representation as the web client:
|
||||
|
||||
```bash
|
||||
box-manifest export
|
||||
box-manifest export --output inventory.json
|
||||
```
|
||||
|
||||
## Raw API access
|
||||
|
||||
The raw command ensures new API operations remain available before a friendly
|
||||
CLI wrapper is added:
|
||||
|
||||
```bash
|
||||
box-manifest api GET /api/tree
|
||||
box-manifest api PATCH /api/nodes/NODE_ID \
|
||||
--data '{"description":"Updated by automation"}'
|
||||
box-manifest api POST /api/nodes/bulk/move --data @request.json
|
||||
```
|
||||
|
||||
Consult [`api/openapi.yaml`](../api/openapi.yaml) for endpoint request and
|
||||
response schemas.
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"box-manifest/server/internal/httpapi"
|
||||
"box-manifest/server/internal/tree"
|
||||
@@ -43,11 +44,7 @@ func main() {
|
||||
apiHandler := httpapi.New(store)
|
||||
mux.Handle("/api/", apiHandler)
|
||||
mux.Handle("/n/", apiHandler)
|
||||
webHandler := http.FileServer(http.Dir(*webPath))
|
||||
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
webHandler.ServeHTTP(w, r)
|
||||
}))
|
||||
mux.Handle("/", newWebHandler(*webPath))
|
||||
|
||||
server := &http.Server{
|
||||
Addr: *address,
|
||||
@@ -58,3 +55,21 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func newWebHandler(webPath string) http.Handler {
|
||||
files := http.FileServer(http.Dir(webPath))
|
||||
indexPath := filepath.Join(webPath, "index.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
requestedPath := filepath.Join(webPath, filepath.FromSlash(strings.TrimPrefix(filepath.Clean(r.URL.Path), "/")))
|
||||
if info, err := os.Stat(requestedPath); err == nil && !info.IsDir() {
|
||||
files.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, indexPath)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWebHandlerServesAssetsAndClientRoutes(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(directory, "index.html"), []byte("<main>app</main>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(directory, "app.js"), []byte("window.app=true"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler := newWebHandler(directory)
|
||||
for _, path := range []string{"/", "/node/ABC123", "/import", "/export", "/types"} {
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "<main>app</main>") {
|
||||
t.Fatalf("route %s: got %d %q", path, response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
asset := httptest.NewRecorder()
|
||||
handler.ServeHTTP(asset, httptest.NewRequest(http.MethodGet, "/app.js", nil))
|
||||
if asset.Code != http.StatusOK || asset.Body.String() != "window.app=true" {
|
||||
t.Fatalf("asset: got %d %q", asset.Code, asset.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"box-manifest/server/internal/apiclient"
|
||||
)
|
||||
|
||||
const defaultServer = "http://box-manifest.hosts.uuard.com:8080"
|
||||
|
||||
type app struct {
|
||||
client *apiclient.Client
|
||||
out io.Writer
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(context.Background(), os.Args[1:], os.Stdout); err != nil {
|
||||
var apiError *apiclient.Error
|
||||
if errors.As(err, &apiError) {
|
||||
_ = json.NewEncoder(os.Stderr).Encode(map[string]any{"error": apiError.Code, "message": apiError.Message, "status": apiError.Status})
|
||||
} else {
|
||||
_ = json.NewEncoder(os.Stderr).Encode(map[string]any{"error": "cli_error", "message": err.Error()})
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, args []string, out io.Writer) error {
|
||||
global := flag.NewFlagSet("box-manifest", flag.ContinueOnError)
|
||||
global.SetOutput(io.Discard)
|
||||
server := global.String("server", envOr("BOX_MANIFEST_SERVER_URL", defaultServer), "server base URL")
|
||||
compact := global.Bool("compact", false, "emit compact JSON")
|
||||
if err := global.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
args = global.Args()
|
||||
if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" {
|
||||
_, _ = fmt.Fprint(out, usage)
|
||||
return nil
|
||||
}
|
||||
client, err := apiclient.New(*server)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a := &app{client: client, out: out}
|
||||
result, err := a.dispatch(ctx, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
if *compact {
|
||||
_, err = fmt.Fprintln(out, string(result))
|
||||
return err
|
||||
}
|
||||
var pretty bytes.Buffer
|
||||
if err := json.Indent(&pretty, result, "", " "); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintln(out, pretty.String())
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *app) dispatch(ctx context.Context, args []string) (json.RawMessage, error) {
|
||||
switch args[0] {
|
||||
case "server":
|
||||
if len(args) == 2 && args[1] == "show" {
|
||||
return a.client.Do(ctx, http.MethodGet, "/api/system/info", nil)
|
||||
}
|
||||
case "tree":
|
||||
return a.client.Do(ctx, http.MethodGet, "/api/tree", nil)
|
||||
case "search":
|
||||
return a.search(ctx, args[1:])
|
||||
case "node":
|
||||
return a.node(ctx, args[1:])
|
||||
case "type":
|
||||
return a.metadata(ctx, "node-types", args[1:])
|
||||
case "tag":
|
||||
return a.metadata(ctx, "tags", args[1:])
|
||||
case "import":
|
||||
return a.importTree(ctx, args[1:])
|
||||
case "export":
|
||||
return a.exportTree(ctx, args[1:])
|
||||
case "api":
|
||||
return a.rawAPI(ctx, args[1:])
|
||||
}
|
||||
return nil, fmt.Errorf("unknown or incomplete command; run box-manifest help")
|
||||
}
|
||||
|
||||
func (a *app) search(ctx context.Context, args []string) (json.RawMessage, error) {
|
||||
if len(args) != 1 || strings.TrimSpace(args[0]) == "" {
|
||||
return nil, fmt.Errorf("usage: box-manifest search QUERY")
|
||||
}
|
||||
raw, err := a.client.Do(ctx, http.MethodGet, "/api/tree", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var root node
|
||||
if err := json.Unmarshal(raw, &root); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := strings.ToLower(args[0])
|
||||
results := make([]searchResult, 0)
|
||||
walk(root, nil, func(n node, parents []string) {
|
||||
if strings.Contains(strings.ToLower(n.Name), query) || strings.Contains(strings.ToLower(n.Description), query) || strings.EqualFold(n.LookupCode, query) {
|
||||
results = append(results, searchResult{ID: n.ID, LookupCode: n.LookupCode, Name: n.Name, Description: n.Description, Quantity: n.Quantity, Path: append(append([]string{}, parents...), n.Name)})
|
||||
}
|
||||
})
|
||||
return marshal(results)
|
||||
}
|
||||
|
||||
func (a *app) node(ctx context.Context, args []string) (json.RawMessage, error) {
|
||||
if len(args) == 0 {
|
||||
return nil, fmt.Errorf("node command required")
|
||||
}
|
||||
switch args[0] {
|
||||
case "get":
|
||||
fs := flags("node get")
|
||||
id, code := fs.String("id", "", "node ID"), fs.String("code", "", "lookup code")
|
||||
if err := fs.Parse(args[1:]); err != nil || (*id == "") == (*code == "") {
|
||||
return nil, fmt.Errorf("usage: box-manifest node get (--id ID | --code CODE)")
|
||||
}
|
||||
if *code != "" {
|
||||
return a.client.Do(ctx, http.MethodGet, "/api/nodes/by-code/"+url.PathEscape(*code), nil)
|
||||
}
|
||||
return a.client.Do(ctx, http.MethodGet, "/api/nodes/"+url.PathEscape(*id), nil)
|
||||
case "create":
|
||||
fs := flags("node create")
|
||||
parent, name := fs.String("parent", "", "parent ID"), fs.String("name", "", "name")
|
||||
description := fs.String("description", "", "description")
|
||||
quantity := optionalInt64Flag(fs, "quantity")
|
||||
if err := fs.Parse(args[1:]); err != nil || strings.TrimSpace(*name) == "" {
|
||||
return nil, fmt.Errorf("usage: box-manifest node create --name NAME [--parent ID] [--description TEXT] [--quantity N]")
|
||||
}
|
||||
body := map[string]any{"name": *name}
|
||||
if *parent != "" {
|
||||
body["parentId"] = *parent
|
||||
}
|
||||
if *description != "" {
|
||||
body["description"] = *description
|
||||
}
|
||||
if quantity.set {
|
||||
body["quantity"] = quantity.value
|
||||
}
|
||||
return a.client.Do(ctx, http.MethodPost, "/api/nodes", body)
|
||||
case "update":
|
||||
fs := flags("node update")
|
||||
id, name := fs.String("id", "", "node ID"), fs.String("name", "", "new name")
|
||||
description := optionalStringFlag(fs, "description")
|
||||
quantity := optionalInt64Flag(fs, "quantity")
|
||||
clearDescription := fs.Bool("clear-description", false, "clear description")
|
||||
clearQuantity := fs.Bool("clear-quantity", false, "clear quantity")
|
||||
if err := fs.Parse(args[1:]); err != nil || *id == "" {
|
||||
return nil, fmt.Errorf("usage: box-manifest node update --id ID [--name NAME] [--description TEXT|--clear-description] [--quantity N|--clear-quantity]")
|
||||
}
|
||||
body := map[string]any{}
|
||||
if *name != "" {
|
||||
body["name"] = *name
|
||||
}
|
||||
if description.set {
|
||||
body["description"] = description.value
|
||||
}
|
||||
if *clearDescription {
|
||||
body["description"] = nil
|
||||
}
|
||||
if quantity.set {
|
||||
body["quantity"] = quantity.value
|
||||
}
|
||||
if *clearQuantity {
|
||||
body["quantity"] = nil
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return nil, fmt.Errorf("at least one update is required")
|
||||
}
|
||||
return a.client.Do(ctx, http.MethodPatch, "/api/nodes/"+url.PathEscape(*id), body)
|
||||
case "move":
|
||||
fs := flags("node move")
|
||||
id, parent := fs.String("id", "", "node ID"), fs.String("parent", "", "target parent ID")
|
||||
children := fs.String("children", "WITH_SUBTREE", "WITH_SUBTREE, PROMOTE_CHILDREN, or CHILDREN_TO_UNSORTED")
|
||||
if err := fs.Parse(args[1:]); err != nil || *id == "" || *parent == "" {
|
||||
return nil, fmt.Errorf("usage: box-manifest node move --id ID --parent ID [--children MODE]")
|
||||
}
|
||||
return a.client.Do(ctx, http.MethodPost, "/api/nodes/"+url.PathEscape(*id)+"/move", map[string]any{"targetParentId": *parent, "childHandling": strings.ToUpper(*children)})
|
||||
case "delete":
|
||||
fs := flags("node delete")
|
||||
id := fs.String("id", "", "node ID")
|
||||
if err := fs.Parse(args[1:]); err != nil || *id == "" {
|
||||
return nil, fmt.Errorf("usage: box-manifest node delete --id ID")
|
||||
}
|
||||
_, err := a.client.Do(ctx, http.MethodDelete, "/api/nodes/"+url.PathEscape(*id), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshal(map[string]any{"deleted": []string{*id}})
|
||||
case "individualize":
|
||||
fs := flags("node individualize")
|
||||
id, names := fs.String("id", "", "node ID"), fs.String("names", "", "comma-separated names")
|
||||
if err := fs.Parse(args[1:]); err != nil || *id == "" || *names == "" {
|
||||
return nil, fmt.Errorf("usage: box-manifest node individualize --id ID --names NAME,NAME,...")
|
||||
}
|
||||
return a.client.Do(ctx, http.MethodPost, "/api/nodes/"+url.PathEscape(*id)+"/individualize", map[string]any{"names": splitList(*names)})
|
||||
case "combine":
|
||||
fs := flags("node combine")
|
||||
ids, retained, name := fs.String("ids", "", "comma-separated node IDs"), fs.String("retain", "", "retained node ID"), fs.String("name", "", "combined name")
|
||||
if err := fs.Parse(args[1:]); err != nil || *ids == "" || *retained == "" || *name == "" {
|
||||
return nil, fmt.Errorf("usage: box-manifest node combine --ids ID,ID --retain ID --name NAME")
|
||||
}
|
||||
return a.client.Do(ctx, http.MethodPost, "/api/nodes/bulk/combine", map[string]any{"nodeIds": splitList(*ids), "retainedNodeId": *retained, "name": *name})
|
||||
case "classify":
|
||||
fs := flags("node classify")
|
||||
id, typeID := fs.String("id", "", "node ID"), fs.String("type", "", "type ID")
|
||||
tags := optionalStringFlag(fs, "tags")
|
||||
clearType := fs.Bool("clear-type", false, "remove type")
|
||||
if err := fs.Parse(args[1:]); err != nil || *id == "" {
|
||||
return nil, fmt.Errorf("usage: box-manifest node classify --id ID [--type ID|--clear-type] [--tags ID,ID]")
|
||||
}
|
||||
currentRaw, err := a.client.Do(ctx, http.MethodGet, "/api/nodes/"+url.PathEscape(*id), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var current node
|
||||
if err := json.Unmarshal(currentRaw, ¤t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := map[string]any{"typeId": current.TypeID, "tagIds": current.TagIDs}
|
||||
if tags.set {
|
||||
body["tagIds"] = splitList(tags.value)
|
||||
}
|
||||
if *clearType {
|
||||
body["typeId"] = nil
|
||||
} else if *typeID != "" {
|
||||
body["typeId"] = *typeID
|
||||
}
|
||||
return a.client.Do(ctx, http.MethodPut, "/api/nodes/"+url.PathEscape(*id)+"/classification", body)
|
||||
case "bulk-create", "bulk-move", "bulk-delete", "bulk-classify":
|
||||
return a.nodeBulk(ctx, args)
|
||||
}
|
||||
return nil, fmt.Errorf("unknown node command %q", args[0])
|
||||
}
|
||||
|
||||
func (a *app) nodeBulk(ctx context.Context, args []string) (json.RawMessage, error) {
|
||||
fs := flags("node " + args[0])
|
||||
ids := fs.String("ids", "", "comma-separated node IDs")
|
||||
parent := fs.String("parent", "", "parent ID")
|
||||
names := fs.String("names", "", "comma-separated names")
|
||||
description := fs.String("description", "", "description")
|
||||
children := fs.String("children", "WITH_SUBTREE", "child handling")
|
||||
typeID := fs.String("type", "", "type ID")
|
||||
setType := fs.Bool("set-type", false, "replace type")
|
||||
addTags := fs.String("add-tags", "", "tag IDs")
|
||||
removeTags := fs.String("remove-tags", "", "tag IDs")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch args[0] {
|
||||
case "bulk-create":
|
||||
if *parent == "" || *names == "" {
|
||||
return nil, fmt.Errorf("usage: box-manifest node bulk-create --parent ID --names NAME,NAME [--description TEXT]")
|
||||
}
|
||||
return a.client.Do(ctx, http.MethodPost, "/api/nodes/bulk", map[string]any{"parentId": *parent, "names": splitList(*names), "description": nullable(*description)})
|
||||
case "bulk-move":
|
||||
if *ids == "" || *parent == "" {
|
||||
return nil, fmt.Errorf("usage: box-manifest node bulk-move --ids ID,ID --parent ID [--children MODE]")
|
||||
}
|
||||
return a.client.Do(ctx, http.MethodPost, "/api/nodes/bulk/move", map[string]any{"nodeIds": splitList(*ids), "targetParentId": *parent, "childHandling": strings.ToUpper(*children)})
|
||||
case "bulk-delete":
|
||||
if *ids == "" {
|
||||
return nil, fmt.Errorf("usage: box-manifest node bulk-delete --ids ID,ID")
|
||||
}
|
||||
values := splitList(*ids)
|
||||
_, err := a.client.Do(ctx, http.MethodPost, "/api/nodes/bulk/delete", map[string]any{"nodeIds": values})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshal(map[string]any{"deleted": values})
|
||||
case "bulk-classify":
|
||||
if *ids == "" {
|
||||
return nil, fmt.Errorf("usage: box-manifest node bulk-classify --ids ID,ID [--set-type --type ID] [--add-tags ID,ID] [--remove-tags ID,ID]")
|
||||
}
|
||||
body := map[string]any{"nodeIds": splitList(*ids), "setType": *setType, "addTagIds": splitList(*addTags), "removeTagIds": splitList(*removeTags)}
|
||||
if *setType {
|
||||
body["typeId"] = nullable(*typeID)
|
||||
}
|
||||
_, err := a.client.Do(ctx, http.MethodPost, "/api/nodes/bulk/classification", body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshal(map[string]any{"updated": splitList(*ids)})
|
||||
}
|
||||
panic("unreachable")
|
||||
}
|
||||
|
||||
func (a *app) metadata(ctx context.Context, resource string, args []string) (json.RawMessage, error) {
|
||||
if len(args) == 0 {
|
||||
return nil, fmt.Errorf("command required")
|
||||
}
|
||||
path := "/api/" + resource
|
||||
if args[0] == "list" {
|
||||
return a.client.Do(ctx, http.MethodGet, path, nil)
|
||||
}
|
||||
fs := flags(resource + " " + args[0])
|
||||
id := fs.String("id", "", "ID")
|
||||
name := fs.String("name", "", "name")
|
||||
description := fs.String("description", "", "description")
|
||||
icon := fs.String("icon", "", "icon key")
|
||||
color := fs.String("color", "", "color")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch args[0] {
|
||||
case "create":
|
||||
if *name == "" {
|
||||
return nil, fmt.Errorf("--name is required")
|
||||
}
|
||||
return a.client.Do(ctx, http.MethodPost, path, metadataBody(resource, *name, *description, *icon, *color))
|
||||
case "update":
|
||||
if *id == "" || *name == "" {
|
||||
return nil, fmt.Errorf("--id and --name are required")
|
||||
}
|
||||
return a.client.Do(ctx, http.MethodPut, path+"/"+url.PathEscape(*id), metadataBody(resource, *name, *description, *icon, *color))
|
||||
case "delete":
|
||||
if *id == "" {
|
||||
return nil, fmt.Errorf("--id is required")
|
||||
}
|
||||
_, err := a.client.Do(ctx, http.MethodDelete, path+"/"+url.PathEscape(*id), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshal(map[string]any{"deleted": []string{*id}})
|
||||
}
|
||||
return nil, fmt.Errorf("unknown command %q", args[0])
|
||||
}
|
||||
|
||||
func (a *app) importTree(ctx context.Context, args []string) (json.RawMessage, error) {
|
||||
if len(args) == 0 {
|
||||
return nil, fmt.Errorf("import command required")
|
||||
}
|
||||
fs := flags("import " + args[0])
|
||||
file := fs.String("file", "-", "manifest file or - for stdin")
|
||||
parent := fs.String("parent", "", "destination parent ID")
|
||||
plan := fs.String("plan", "", "preview plan ID")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args[0] == "commit" {
|
||||
if *plan == "" {
|
||||
return nil, fmt.Errorf("usage: box-manifest import commit --plan ID")
|
||||
}
|
||||
return a.client.Do(ctx, http.MethodPost, "/api/imports/tree/commit", map[string]any{"planId": *plan})
|
||||
}
|
||||
if args[0] != "preview" || *parent == "" {
|
||||
return nil, fmt.Errorf("usage: box-manifest import preview --parent ID [--file FILE]")
|
||||
}
|
||||
data, err := readFile(*file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var manifest any
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("invalid manifest JSON: %w", err)
|
||||
}
|
||||
return a.client.Do(ctx, http.MethodPost, "/api/imports/tree/preview", map[string]any{"parentId": *parent, "manifest": manifest})
|
||||
}
|
||||
|
||||
func (a *app) rawAPI(ctx context.Context, args []string) (json.RawMessage, error) {
|
||||
if len(args) < 2 {
|
||||
return nil, fmt.Errorf("usage: box-manifest api METHOD PATH [--data JSON|@FILE]")
|
||||
}
|
||||
fs := flags("api")
|
||||
data := fs.String("data", "", "JSON or @FILE")
|
||||
if err := fs.Parse(args[2:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var body any
|
||||
if *data != "" {
|
||||
content := []byte(*data)
|
||||
if strings.HasPrefix(*data, "@") {
|
||||
var err error
|
||||
content, err = os.ReadFile(strings.TrimPrefix(*data, "@"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal(content, &body); err != nil {
|
||||
return nil, fmt.Errorf("invalid request JSON: %w", err)
|
||||
}
|
||||
}
|
||||
return a.client.Do(ctx, strings.ToUpper(args[0]), args[1], body)
|
||||
}
|
||||
|
||||
type node struct {
|
||||
ID, LookupCode, Name, Description string
|
||||
ParentID *string `json:"parentId"`
|
||||
Quantity *int64 `json:"quantity"`
|
||||
TypeID *string `json:"typeId"`
|
||||
TagIDs []string `json:"tagIds"`
|
||||
Children []node `json:"children"`
|
||||
}
|
||||
type searchResult struct {
|
||||
ID string `json:"id"`
|
||||
LookupCode string `json:"lookupCode"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Quantity *int64 `json:"quantity,omitempty"`
|
||||
Path []string `json:"path"`
|
||||
}
|
||||
|
||||
func walk(n node, parents []string, visit func(node, []string)) {
|
||||
visit(n, parents)
|
||||
next := append(append([]string{}, parents...), n.Name)
|
||||
for _, child := range n.Children {
|
||||
walk(child, next, visit)
|
||||
}
|
||||
}
|
||||
func marshal(value any) (json.RawMessage, error) { data, err := json.Marshal(value); return data, err }
|
||||
func nullable(value string) any {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
func splitList(value string) []string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return []string{}
|
||||
}
|
||||
parts := strings.Split(value, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if value := strings.TrimSpace(part); value != "" {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
func metadataBody(resource, name, description, icon, color string) map[string]any {
|
||||
body := map[string]any{"name": name, "color": nullable(color)}
|
||||
if resource == "node-types" {
|
||||
body["description"] = nullable(description)
|
||||
body["iconKey"] = nullable(icon)
|
||||
}
|
||||
return body
|
||||
}
|
||||
func flags(name string) *flag.FlagSet {
|
||||
fs := flag.NewFlagSet(name, flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
return fs
|
||||
}
|
||||
func envOr(name, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
func readFile(path string) ([]byte, error) {
|
||||
if path == "-" {
|
||||
return io.ReadAll(os.Stdin)
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
|
||||
type optionalString struct {
|
||||
value string
|
||||
set bool
|
||||
}
|
||||
|
||||
func (o *optionalString) String() string { return o.value }
|
||||
func (o *optionalString) Set(value string) error { o.value, o.set = value, true; return nil }
|
||||
func optionalStringFlag(fs *flag.FlagSet, name string) *optionalString {
|
||||
value := &optionalString{}
|
||||
fs.Var(value, name, name)
|
||||
return value
|
||||
}
|
||||
|
||||
type optionalInt64 struct {
|
||||
value int64
|
||||
set bool
|
||||
}
|
||||
|
||||
func (o *optionalInt64) String() string { return strconv.FormatInt(o.value, 10) }
|
||||
func (o *optionalInt64) Set(value string) error {
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || parsed < 0 {
|
||||
return fmt.Errorf("must be a non-negative integer")
|
||||
}
|
||||
o.value, o.set = parsed, true
|
||||
return nil
|
||||
}
|
||||
func optionalInt64Flag(fs *flag.FlagSet, name string) *optionalInt64 {
|
||||
value := &optionalInt64{}
|
||||
fs.Var(value, name, name)
|
||||
return value
|
||||
}
|
||||
|
||||
func (a *app) exportTree(ctx context.Context, args []string) (json.RawMessage, error) {
|
||||
fs := flags("export")
|
||||
output := fs.String("output", "", "write JSON to a file")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values := make([]json.RawMessage, 3)
|
||||
paths := []string{"/api/tree", "/api/node-types", "/api/tags"}
|
||||
for index := range paths {
|
||||
raw, err := a.client.Do(ctx, http.MethodGet, paths[index], nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values[index] = raw
|
||||
}
|
||||
var root node
|
||||
var types, tags []map[string]any
|
||||
if err := json.Unmarshal(values[0], &root); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal(values[1], &types)
|
||||
_ = json.Unmarshal(values[2], &tags)
|
||||
typeNames, tagNames := namesByID(types), namesByID(tags)
|
||||
usedTypes, usedTags := map[string]bool{}, map[string]bool{}
|
||||
var encode func(node) map[string]any
|
||||
encode = func(n node) map[string]any {
|
||||
result := map[string]any{"name": n.Name, "lookupCode": n.LookupCode}
|
||||
if n.Description != "" {
|
||||
result["description"] = n.Description
|
||||
}
|
||||
if n.Quantity != nil {
|
||||
result["quantity"] = *n.Quantity
|
||||
}
|
||||
if n.TypeID != nil {
|
||||
usedTypes[*n.TypeID] = true
|
||||
if name := typeNames[*n.TypeID]; name != "" {
|
||||
result["type"] = name
|
||||
}
|
||||
}
|
||||
if len(n.TagIDs) > 0 {
|
||||
names := []string{}
|
||||
for _, id := range n.TagIDs {
|
||||
usedTags[id] = true
|
||||
if name := tagNames[id]; name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
result["tags"] = names
|
||||
}
|
||||
if len(n.Children) > 0 {
|
||||
children := make([]map[string]any, len(n.Children))
|
||||
for i := range n.Children {
|
||||
children[i] = encode(n.Children[i])
|
||||
}
|
||||
result["children"] = children
|
||||
}
|
||||
return result
|
||||
}
|
||||
nodes := make([]map[string]any, len(root.Children))
|
||||
for i := range root.Children {
|
||||
nodes[i] = encode(root.Children[i])
|
||||
}
|
||||
payload := map[string]any{"format": "box-manifest-portable", "exportedAt": time.Now().UTC().Format(time.RFC3339), "note": "Internal UUIDs are intentionally omitted. Lookup codes are included as human-readable references and may change when re-imported.", "types": filterMetadata(types, usedTypes, "description", "iconKey", "color"), "tags": filterMetadata(tags, usedTags, "color"), "nodes": nodes}
|
||||
raw, err := marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if *output != "" {
|
||||
var pretty bytes.Buffer
|
||||
if err := json.Indent(&pretty, raw, "", " "); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(*output, append(pretty.Bytes(), '\n'), 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshal(map[string]any{"output": *output})
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func namesByID(values []map[string]any) map[string]string {
|
||||
result := map[string]string{}
|
||||
for _, value := range values {
|
||||
id, _ := value["id"].(string)
|
||||
name, _ := value["name"].(string)
|
||||
result[id] = name
|
||||
}
|
||||
return result
|
||||
}
|
||||
func filterMetadata(values []map[string]any, used map[string]bool, optional ...string) []map[string]any {
|
||||
result := []map[string]any{}
|
||||
for _, value := range values {
|
||||
id, _ := value["id"].(string)
|
||||
if !used[id] {
|
||||
continue
|
||||
}
|
||||
item := map[string]any{"name": value["name"]}
|
||||
for _, key := range optional {
|
||||
if value[key] != nil && value[key] != "" {
|
||||
item[key] = value[key]
|
||||
}
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return fmt.Sprint(result[i]["name"]) < fmt.Sprint(result[j]["name"]) })
|
||||
return result
|
||||
}
|
||||
|
||||
const usage = `box-manifest — JSON-first Box Manifest CLI
|
||||
|
||||
Global flags (before the command):
|
||||
--server URL API server (or BOX_MANIFEST_SERVER_URL)
|
||||
--compact emit one-line JSON
|
||||
|
||||
Commands:
|
||||
server show
|
||||
tree
|
||||
search QUERY
|
||||
node get|create|update|move|delete|individualize|combine|classify
|
||||
node bulk-create|bulk-move|bulk-delete|bulk-classify
|
||||
type list|create|update|delete
|
||||
tag list|create|update|delete
|
||||
import preview|commit
|
||||
export [--output FILE]
|
||||
api METHOD PATH [--data JSON|@FILE]
|
||||
|
||||
Run a subcommand with the required flags shown by an error, or use the raw api
|
||||
command for direct access to any API endpoint.
|
||||
`
|
||||
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSearchProducesStructuredPaths(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/tree" {
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"id":"root","lookupCode":"ROOT00","name":"Root","description":null,"quantity":null,"tagIds":[],"children":[{"id":"a","lookupCode":"ABC234","name":"Blue bin","description":"Fasteners","quantity":null,"tagIds":[],"children":[]}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
var output bytes.Buffer
|
||||
if err := run(context.Background(), []string{"--server", server.URL, "search", "fast"}, &output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(output.String(), `"path": [`) || !strings.Contains(output.String(), `"Blue bin"`) {
|
||||
t.Fatalf("unexpected output: %s", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpDoesNotRequireServer(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
if err := run(context.Background(), []string{"help"}, &output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(output.String(), "JSON-first") {
|
||||
t.Fatalf("unexpected help: %s", output.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package apiclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
type Error struct {
|
||||
Status int
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
if e.Code == "" {
|
||||
return fmt.Sprintf("server returned HTTP %d: %s", e.Status, e.Message)
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
func New(baseURL string) (*Client, error) {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
parsed, err := url.Parse(baseURL)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||||
return nil, fmt.Errorf("invalid server URL %q", baseURL)
|
||||
}
|
||||
return &Client{BaseURL: baseURL, HTTP: http.DefaultClient}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Do(ctx context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
var input io.Reader
|
||||
if body != nil {
|
||||
encoded, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
input = bytes.NewReader(encoded)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
response, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
data, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
apiError := &Error{Status: response.StatusCode, Message: strings.TrimSpace(string(data))}
|
||||
_ = json.Unmarshal(data, apiError)
|
||||
return nil, apiError
|
||||
}
|
||||
if len(bytes.TrimSpace(data)) == 0 {
|
||||
return json.RawMessage(`{}`), nil
|
||||
}
|
||||
if !json.Valid(data) {
|
||||
return nil, fmt.Errorf("server returned invalid JSON")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package apiclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDoReturnsStructuredAPIError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_, _ = w.Write([]byte(`{"code":"not_empty","message":"node is not empty"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
client, _ := New(server.URL)
|
||||
_, err := client.Do(context.Background(), http.MethodDelete, "/api/nodes/a", nil)
|
||||
apiError, ok := err.(*Error)
|
||||
if !ok || apiError.Status != http.StatusConflict || apiError.Code != "not_empty" {
|
||||
t.Fatalf("unexpected error: %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsNonHTTPURL(t *testing.T) {
|
||||
if _, err := New("file:///tmp/data"); err == nil {
|
||||
t.Fatal("expected invalid URL error")
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ type Handler struct {
|
||||
func New(store *tree.Store) http.Handler {
|
||||
h := &Handler{store: store, imports: importer.New(store)}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /api/system/info", h.getSystemInfo)
|
||||
mux.HandleFunc("GET /api/tree", h.getTree)
|
||||
mux.HandleFunc("GET /api/node-types", h.listNodeTypes)
|
||||
mux.HandleFunc("POST /api/node-types", h.createNodeType)
|
||||
@@ -30,6 +31,7 @@ func New(store *tree.Store) http.Handler {
|
||||
mux.HandleFunc("DELETE /api/tags/{tagId}", h.deleteTag)
|
||||
mux.HandleFunc("POST /api/nodes", h.createNode)
|
||||
mux.HandleFunc("POST /api/nodes/bulk", h.createNodesBulk)
|
||||
mux.HandleFunc("PATCH /api/nodes/bulk", h.updateNodesBulk)
|
||||
mux.HandleFunc("POST /api/nodes/bulk/move", h.moveNodesBulk)
|
||||
mux.HandleFunc("POST /api/nodes/bulk/delete", h.deleteNodesBulk)
|
||||
mux.HandleFunc("POST /api/nodes/bulk/combine", h.combineNodes)
|
||||
@@ -47,7 +49,23 @@ func New(store *tree.Store) http.Handler {
|
||||
return mux
|
||||
}
|
||||
|
||||
type systemInfoResponse struct {
|
||||
ServerID string `json:"serverId"`
|
||||
Name string `json:"name"`
|
||||
APIVersion int `json:"apiVersion"`
|
||||
}
|
||||
|
||||
func (h *Handler) getSystemInfo(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := h.store.ServerID(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, systemInfoResponse{ServerID: id, Name: "Box Manifest", APIVersion: 1})
|
||||
}
|
||||
|
||||
type createNodeRequest struct {
|
||||
ClientID *string `json:"clientId"`
|
||||
ParentID *string `json:"parentId"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
@@ -78,6 +96,11 @@ type deleteNodesBulkRequest struct {
|
||||
NodeIDs []string `json:"nodeIds"`
|
||||
}
|
||||
|
||||
type updateNodesBulkRequest struct {
|
||||
NodeIDs []string `json:"nodeIds"`
|
||||
Description optionalString `json:"description"`
|
||||
}
|
||||
|
||||
type individualizeNodeRequest struct {
|
||||
Names []string `json:"names"`
|
||||
}
|
||||
@@ -154,9 +177,10 @@ func (o *optionalInt64) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
type updateNodeRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Description optionalString `json:"description"`
|
||||
Quantity optionalInt64 `json:"quantity"`
|
||||
Name *string `json:"name"`
|
||||
Description optionalString `json:"description"`
|
||||
Quantity optionalInt64 `json:"quantity"`
|
||||
BaseRevision *int64 `json:"baseRevision"`
|
||||
}
|
||||
|
||||
type moveNodeRequest struct {
|
||||
@@ -267,7 +291,7 @@ func (h *Handler) createNode(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, tree.ErrInvalid)
|
||||
return
|
||||
}
|
||||
node, err := h.store.Create(r.Context(), request.ParentID, request.Name, request.Description, request.Quantity)
|
||||
node, err := h.store.CreateWithID(r.Context(), request.ClientID, request.ParentID, request.Name, request.Description, request.Quantity)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
@@ -318,6 +342,19 @@ func (h *Handler) deleteNodesBulk(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) updateNodesBulk(w http.ResponseWriter, r *http.Request) {
|
||||
var request updateNodesBulkRequest
|
||||
if err := decodeJSON(r, &request); err != nil || !request.Description.Set {
|
||||
writeError(w, tree.ErrInvalid)
|
||||
return
|
||||
}
|
||||
if err := h.store.UpdateDescriptions(r.Context(), request.NodeIDs, request.Description.Value); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) individualizeNode(w http.ResponseWriter, r *http.Request) {
|
||||
var request individualizeNodeRequest
|
||||
if err := decodeJSON(r, &request); err != nil {
|
||||
@@ -437,7 +474,7 @@ func (h *Handler) updateNode(w http.ResponseWriter, r *http.Request) {
|
||||
node, err := h.store.Update(r.Context(), r.PathValue("nodeId"), request.Name, tree.StringUpdate{Set: request.Description.Set, Value: request.Description.Value}, tree.QuantityUpdate{
|
||||
Set: request.Quantity.Set,
|
||||
Value: request.Quantity.Value,
|
||||
})
|
||||
}, request.BaseRevision)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
@@ -500,6 +537,9 @@ func writeError(w http.ResponseWriter, err error) {
|
||||
case errors.Is(err, tree.ErrConflict):
|
||||
status = http.StatusConflict
|
||||
response = errorResponse{Code: "TREE_CONFLICT", Message: "operation conflicts with tree rules"}
|
||||
case errors.Is(err, tree.ErrRevisionConflict):
|
||||
status = http.StatusConflict
|
||||
response = errorResponse{Code: "REVISION_CONFLICT", Message: "node changed since it was read"}
|
||||
case errors.Is(err, tree.ErrInvalid):
|
||||
status = http.StatusBadRequest
|
||||
response = errorResponse{Code: "INVALID_REQUEST", Message: "invalid request"}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -13,6 +14,25 @@ import (
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func TestSystemInfoIsStable(t *testing.T) {
|
||||
handler := newTestHandler(t)
|
||||
first := request(t, handler, http.MethodGet, "/api/system/info", "")
|
||||
second := request(t, handler, http.MethodGet, "/api/system/info", "")
|
||||
if first.Code != http.StatusOK || second.Code != http.StatusOK {
|
||||
t.Fatalf("system info: got %d and %d", first.Code, second.Code)
|
||||
}
|
||||
var firstInfo, secondInfo systemInfoResponse
|
||||
if err := json.Unmarshal(first.Body.Bytes(), &firstInfo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(second.Body.Bytes(), &secondInfo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if firstInfo.ServerID == "" || firstInfo.ServerID != secondInfo.ServerID || firstInfo.APIVersion != 1 {
|
||||
t.Fatalf("unexpected system info: %#v, %#v", firstInfo, secondInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeLifecycleThroughHTTP(t *testing.T) {
|
||||
handler := newTestHandler(t)
|
||||
|
||||
@@ -61,6 +81,40 @@ func TestNodeLifecycleThroughHTTP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRevisionConflictThroughHTTP(t *testing.T) {
|
||||
handler := newTestHandler(t)
|
||||
node := createNodeThroughHTTP(t, handler, tree.UnsortedID, "Box")
|
||||
first := request(t, handler, http.MethodPatch, "/api/nodes/"+node.ID, fmt.Sprintf(`{"name":"New","baseRevision":%d}`, node.Revision))
|
||||
if first.Code != http.StatusOK {
|
||||
t.Fatalf("first update: %d %s", first.Code, first.Body.String())
|
||||
}
|
||||
stale := request(t, handler, http.MethodPatch, "/api/nodes/"+node.ID, fmt.Sprintf(`{"name":"Stale","baseRevision":%d}`, node.Revision))
|
||||
if stale.Code != http.StatusConflict || !bytes.Contains(stale.Body.Bytes(), []byte("REVISION_CONFLICT")) {
|
||||
t.Fatalf("stale update: %d %s", stale.Code, stale.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientGeneratedNodeIDMakesCreateRetryIdempotent(t *testing.T) {
|
||||
handler := newTestHandler(t)
|
||||
id := "0123456789abcdef0123456789abcdef"
|
||||
body := `{"clientId":"` + id + `","parentId":"unsorted","name":"Offline box"}`
|
||||
first := request(t, handler, http.MethodPost, "/api/nodes", body)
|
||||
second := request(t, handler, http.MethodPost, "/api/nodes", body)
|
||||
if first.Code != http.StatusCreated || second.Code != http.StatusCreated {
|
||||
t.Fatalf("create retries: %d %s; %d %s", first.Code, first.Body.String(), second.Code, second.Body.String())
|
||||
}
|
||||
var firstNode, secondNode tree.Node
|
||||
if err := json.Unmarshal(first.Body.Bytes(), &firstNode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(second.Body.Bytes(), &secondNode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if firstNode.ID != id || secondNode.ID != id || firstNode.LookupCode != secondNode.LookupCode {
|
||||
t.Fatalf("retry changed identity: %#v %#v", firstNode, secondNode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkCreateThroughHTTP(t *testing.T) {
|
||||
handler := newTestHandler(t)
|
||||
response := request(
|
||||
@@ -103,6 +157,26 @@ func TestBulkMoveAndDeleteThroughHTTP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkDescriptionUpdateThroughHTTP(t *testing.T) {
|
||||
handler := newTestHandler(t)
|
||||
first := createNodeThroughHTTP(t, handler, tree.UnsortedID, "first")
|
||||
second := createNodeThroughHTTP(t, handler, tree.UnsortedID, "second")
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"nodeIds": []string{first.ID, second.ID},
|
||||
"description": "Shared description",
|
||||
})
|
||||
response := request(t, handler, http.MethodPatch, "/api/nodes/bulk", string(body))
|
||||
if response.Code != http.StatusNoContent {
|
||||
t.Fatalf("bulk description: got %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
for _, id := range []string{first.ID, second.ID} {
|
||||
updated := request(t, handler, http.MethodGet, "/api/nodes/"+id, "")
|
||||
if updated.Code != http.StatusOK || !bytes.Contains(updated.Body.Bytes(), []byte(`"description":"Shared description"`)) {
|
||||
t.Fatalf("description missing: %d %s", updated.Code, updated.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndividualizeAndCombineThroughHTTP(t *testing.T) {
|
||||
handler := newTestHandler(t)
|
||||
create := request(t, handler, http.MethodPost, "/api/nodes", `{"parentId":"unsorted","name":"Bin","quantity":2}`)
|
||||
|
||||
+115
-26
@@ -17,9 +17,10 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("node not found")
|
||||
ErrConflict = errors.New("operation conflicts with tree rules")
|
||||
ErrInvalid = errors.New("invalid node data")
|
||||
ErrNotFound = errors.New("node not found")
|
||||
ErrConflict = errors.New("operation conflicts with tree rules")
|
||||
ErrRevisionConflict = errors.New("node changed since it was read")
|
||||
ErrInvalid = errors.New("invalid node data")
|
||||
)
|
||||
|
||||
type Node struct {
|
||||
@@ -31,6 +32,7 @@ type Node struct {
|
||||
Quantity *int64 `json:"quantity"`
|
||||
TypeID *string `json:"typeId"`
|
||||
TagIDs []string `json:"tagIds"`
|
||||
Revision int64 `json:"revision"`
|
||||
}
|
||||
|
||||
type TreeNode struct {
|
||||
@@ -81,6 +83,19 @@ func (s *Store) Initialize(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS system_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("create system settings table: %w", err)
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO system_settings (key, value)
|
||||
VALUES ('server_id', lower(hex(randomblob(16))))
|
||||
ON CONFLICT(key) DO NOTHING`); err != nil {
|
||||
return fmt.Errorf("create server identity: %w", err)
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS node_types (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -117,7 +132,7 @@ func (s *Store) Initialize(ctx context.Context) error {
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO nodes (id, parent_id, name, system)
|
||||
VALUES (?, NULL, 'Root', 1)
|
||||
VALUES (?, NULL, 'Inventory', 1)
|
||||
ON CONFLICT(id) DO NOTHING`, RootID); err != nil {
|
||||
return fmt.Errorf("create root: %w", err)
|
||||
}
|
||||
@@ -146,6 +161,11 @@ func (s *Store) Initialize(ctx context.Context) error {
|
||||
return fmt.Errorf("add node type: %w", err)
|
||||
}
|
||||
}
|
||||
if !columns["revision"] {
|
||||
if _, err = tx.ExecContext(ctx, `ALTER TABLE nodes ADD COLUMN revision INTEGER NOT NULL DEFAULT 1`); err != nil {
|
||||
return fmt.Errorf("add node revision: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS node_tags (
|
||||
node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
@@ -185,7 +205,19 @@ func (s *Store) Initialize(ctx context.Context) error {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) ServerID(ctx context.Context) (string, error) {
|
||||
var id string
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT value FROM system_settings WHERE key = 'server_id'`).Scan(&id); err != nil {
|
||||
return "", fmt.Errorf("read server identity: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *Store) Create(ctx context.Context, parentID *string, name string, description *string, quantity *int64) (Node, error) {
|
||||
return s.CreateWithID(ctx, nil, parentID, name, description, quantity)
|
||||
}
|
||||
|
||||
func (s *Store) CreateWithID(ctx context.Context, requestedID, parentID *string, name string, description *string, quantity *int64) (Node, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
description = cleanOptionalString(description)
|
||||
if name == "" || quantity != nil && *quantity < 0 {
|
||||
@@ -199,9 +231,23 @@ func (s *Store) Create(ctx context.Context, parentID *string, name string, descr
|
||||
return Node{}, err
|
||||
}
|
||||
|
||||
id, err := newID()
|
||||
if err != nil {
|
||||
return Node{}, fmt.Errorf("generate node id: %w", err)
|
||||
var id string
|
||||
if requestedID == nil {
|
||||
generated, err := newID()
|
||||
if err != nil {
|
||||
return Node{}, fmt.Errorf("generate node id: %w", err)
|
||||
}
|
||||
id = generated
|
||||
} else {
|
||||
id = strings.TrimSpace(*requestedID)
|
||||
if len(id) != 32 {
|
||||
return Node{}, ErrInvalid
|
||||
}
|
||||
if existing, err := s.Get(ctx, id); err == nil {
|
||||
return existing, nil
|
||||
} else if !errors.Is(err, ErrNotFound) {
|
||||
return Node{}, err
|
||||
}
|
||||
}
|
||||
code, err := newLookupCode(ctx, s.db)
|
||||
if err != nil {
|
||||
@@ -322,7 +368,7 @@ func (s *Store) CreateTree(ctx context.Context, parentID string, nodes []ImportN
|
||||
|
||||
func (s *Store) Get(ctx context.Context, id string) (Node, error) {
|
||||
node, err := scanNode(s.db.QueryRowContext(ctx,
|
||||
`SELECT id, lookup_code, parent_id, name, description, quantity, type_id FROM nodes WHERE id = ?`, id,
|
||||
`SELECT id, lookup_code, parent_id, name, description, quantity, type_id, revision FROM nodes WHERE id = ?`, id,
|
||||
))
|
||||
if err != nil {
|
||||
return Node{}, err
|
||||
@@ -335,7 +381,7 @@ func (s *Store) Get(ctx context.Context, id string) (Node, error) {
|
||||
|
||||
func (s *Store) GetByLookupCode(ctx context.Context, code string) (Node, error) {
|
||||
node, err := scanNode(s.db.QueryRowContext(ctx,
|
||||
`SELECT id, lookup_code, parent_id, name, description, quantity, type_id FROM nodes WHERE lookup_code = ?`,
|
||||
`SELECT id, lookup_code, parent_id, name, description, quantity, type_id, revision FROM nodes WHERE lookup_code = ?`,
|
||||
strings.ToUpper(strings.TrimSpace(code)),
|
||||
))
|
||||
if err != nil {
|
||||
@@ -349,7 +395,7 @@ func (s *Store) GetByLookupCode(ctx context.Context, code string) (Node, error)
|
||||
|
||||
func (s *Store) Tree(ctx context.Context) (*TreeNode, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, lookup_code, parent_id, name, description, quantity, type_id FROM nodes ORDER BY rowid`)
|
||||
`SELECT id, lookup_code, parent_id, name, description, quantity, type_id, revision FROM nodes ORDER BY rowid`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -392,7 +438,7 @@ func (s *Store) Tree(ctx context.Context) (*TreeNode, error) {
|
||||
return root, nil
|
||||
}
|
||||
|
||||
func (s *Store) Update(ctx context.Context, id string, name *string, description StringUpdate, quantity QuantityUpdate) (Node, error) {
|
||||
func (s *Store) Update(ctx context.Context, id string, name *string, description StringUpdate, quantity QuantityUpdate, baseRevision *int64) (Node, error) {
|
||||
if name == nil && !description.Set && !quantity.Set {
|
||||
return Node{}, ErrInvalid
|
||||
}
|
||||
@@ -402,7 +448,7 @@ func (s *Store) Update(ctx context.Context, id string, name *string, description
|
||||
return Node{}, ErrInvalid
|
||||
}
|
||||
name = &trimmed
|
||||
if id == RootID || id == UnsortedID {
|
||||
if id == UnsortedID {
|
||||
return Node{}, ErrConflict
|
||||
}
|
||||
}
|
||||
@@ -412,27 +458,70 @@ func (s *Store) Update(ctx context.Context, id string, name *string, description
|
||||
if description.Set {
|
||||
description.Value = cleanOptionalString(description.Value)
|
||||
}
|
||||
if _, err := s.Get(ctx, id); err != nil {
|
||||
current, err := s.Get(ctx, id)
|
||||
if err != nil {
|
||||
return Node{}, err
|
||||
}
|
||||
if name != nil {
|
||||
if _, err := s.db.ExecContext(ctx, `UPDATE nodes SET name = ? WHERE id = ?`, *name, id); err != nil {
|
||||
return Node{}, err
|
||||
}
|
||||
if baseRevision != nil && current.Revision != *baseRevision {
|
||||
return Node{}, ErrRevisionConflict
|
||||
}
|
||||
if quantity.Set {
|
||||
if _, err := s.db.ExecContext(ctx, `UPDATE nodes SET quantity = ? WHERE id = ?`, quantity.Value, id); err != nil {
|
||||
return Node{}, err
|
||||
}
|
||||
newName := current.Name
|
||||
newDescription := current.Description
|
||||
newQuantity := current.Quantity
|
||||
if name != nil {
|
||||
newName = *name
|
||||
}
|
||||
if description.Set {
|
||||
if _, err := s.db.ExecContext(ctx, `UPDATE nodes SET description = ? WHERE id = ?`, description.Value, id); err != nil {
|
||||
return Node{}, err
|
||||
}
|
||||
newDescription = description.Value
|
||||
}
|
||||
if quantity.Set {
|
||||
newQuantity = quantity.Value
|
||||
}
|
||||
query := `UPDATE nodes SET name = ?, description = ?, quantity = ?, revision = revision + 1 WHERE id = ?`
|
||||
arguments := []any{newName, newDescription, newQuantity, id}
|
||||
if baseRevision != nil {
|
||||
query += ` AND revision = ?`
|
||||
arguments = append(arguments, *baseRevision)
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, query, arguments...)
|
||||
if err != nil {
|
||||
return Node{}, err
|
||||
}
|
||||
changed, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return Node{}, err
|
||||
}
|
||||
if changed != 1 {
|
||||
return Node{}, ErrRevisionConflict
|
||||
}
|
||||
return s.Get(ctx, id)
|
||||
}
|
||||
|
||||
// UpdateDescriptions replaces the description on every selected node in one
|
||||
// transaction. A nil or blank description clears the field.
|
||||
func (s *Store) UpdateDescriptions(ctx context.Context, ids []string, description *string) error {
|
||||
if len(ids) == 0 || hasDuplicateIDs(ids) {
|
||||
return ErrInvalid
|
||||
}
|
||||
description = cleanOptionalString(description)
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, id := range ids {
|
||||
if _, err := getWith(ctx, tx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, id := range ids {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE nodes SET description = ?, revision = revision + 1 WHERE id = ?`, description, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) Delete(ctx context.Context, id string) error {
|
||||
if id == RootID || id == UnsortedID {
|
||||
return ErrConflict
|
||||
@@ -783,7 +872,7 @@ func scanNode(row scanner) (Node, error) {
|
||||
var description sql.NullString
|
||||
var quantity sql.NullInt64
|
||||
var typeID sql.NullString
|
||||
if err := row.Scan(&node.ID, &node.LookupCode, &parent, &node.Name, &description, &quantity, &typeID); err != nil {
|
||||
if err := row.Scan(&node.ID, &node.LookupCode, &parent, &node.Name, &description, &quantity, &typeID, &node.Revision); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Node{}, ErrNotFound
|
||||
}
|
||||
@@ -807,7 +896,7 @@ func scanNode(row scanner) (Node, error) {
|
||||
|
||||
func getWith(ctx context.Context, tx *sql.Tx, id string) (Node, error) {
|
||||
return scanNode(tx.QueryRowContext(ctx,
|
||||
`SELECT id, lookup_code, parent_id, name, description, quantity, type_id FROM nodes WHERE id = ?`, id))
|
||||
`SELECT id, lookup_code, parent_id, name, description, quantity, type_id, revision FROM nodes WHERE id = ?`, id))
|
||||
}
|
||||
|
||||
func isDescendant(ctx context.Context, tx *sql.Tx, ancestorID, candidateID string) (bool, error) {
|
||||
|
||||
@@ -13,11 +13,22 @@ import (
|
||||
|
||||
func TestInitializeCreatesProtectedTree(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
firstID, err := store.ServerID(context.Background())
|
||||
if err != nil || firstID == "" {
|
||||
t.Fatalf("expected server identity, got %q, %v", firstID, err)
|
||||
}
|
||||
if err := store.Initialize(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondID, err := store.ServerID(context.Background())
|
||||
if err != nil || secondID != firstID {
|
||||
t.Fatalf("server identity changed: %q -> %q, %v", firstID, secondID, err)
|
||||
}
|
||||
root, err := store.Tree(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if root.ID != RootID || root.ParentID != nil || root.Name != "Root" {
|
||||
if root.ID != RootID || root.ParentID != nil || root.Name != "Inventory" {
|
||||
t.Fatalf("unexpected root: %#v", root.Node)
|
||||
}
|
||||
if len(root.Children) != 1 || root.Children[0].ID != UnsortedID {
|
||||
@@ -62,7 +73,7 @@ func TestLookupCodeFindsNodeAndSurvivesMoveAndRename(t *testing.T) {
|
||||
node := createTestNode(t, store, RootID, "Box")
|
||||
code := node.LookupCode
|
||||
newName := "Renamed box"
|
||||
if _, err := store.Update(ctx, node.ID, &newName, StringUpdate{}, QuantityUpdate{}); err != nil {
|
||||
if _, err := store.Update(ctx, node.ID, &newName, StringUpdate{}, QuantityUpdate{}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.Move(ctx, node.ID, UnsortedID, WithSubtree); err != nil {
|
||||
@@ -77,6 +88,24 @@ func TestLookupCodeFindsNodeAndSurvivesMoveAndRename(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRejectsStaleRevision(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
node := createTestNode(t, store, RootID, "Box")
|
||||
name := "First edit"
|
||||
updated, err := store.Update(ctx, node.ID, &name, StringUpdate{}, QuantityUpdate{}, &node.Revision)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.Revision != node.Revision+1 {
|
||||
t.Fatalf("revision did not advance: before=%d after=%d", node.Revision, updated.Revision)
|
||||
}
|
||||
staleName := "Stale edit"
|
||||
if _, err := store.Update(ctx, node.ID, &staleName, StringUpdate{}, QuantityUpdate{}, &node.Revision); !errors.Is(err, ErrRevisionConflict) {
|
||||
t.Fatalf("expected revision conflict, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveWithSubtree(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
@@ -151,7 +180,7 @@ func TestDeleteOnlyAllowsEmptyNodes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectedNodesCannotBeMovedDeletedOrRenamed(t *testing.T) {
|
||||
func TestProtectedNodesCannotBeMovedOrDeletedAndUnsortedCannotBeRenamed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
newName := "Elsewhere"
|
||||
@@ -163,9 +192,13 @@ func TestProtectedNodesCannotBeMovedDeletedOrRenamed(t *testing.T) {
|
||||
if _, err := store.Move(ctx, id, RootID, WithSubtree); !errors.Is(err, ErrConflict) {
|
||||
t.Errorf("move %s: expected conflict, got %v", id, err)
|
||||
}
|
||||
if _, err := store.Update(ctx, id, &newName, StringUpdate{}, QuantityUpdate{}); !errors.Is(err, ErrConflict) {
|
||||
t.Errorf("rename %s: expected conflict, got %v", id, err)
|
||||
}
|
||||
}
|
||||
if _, err := store.Update(ctx, UnsortedID, &newName, StringUpdate{}, QuantityUpdate{}, nil); !errors.Is(err, ErrConflict) {
|
||||
t.Errorf("rename unsorted: expected conflict, got %v", err)
|
||||
}
|
||||
updated, err := store.Update(ctx, RootID, &newName, StringUpdate{}, QuantityUpdate{}, nil)
|
||||
if err != nil || updated.Name != newName {
|
||||
t.Fatalf("rename root: got %#v, %v", updated, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,6 +377,36 @@ func TestCombineRejectsDifferentParentsWithoutChanges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDescriptionsIsAtomicAndCanClear(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
first := createTestNode(t, store, UnsortedID, "first")
|
||||
second := createTestNode(t, store, UnsortedID, "second")
|
||||
description := "Shared description"
|
||||
if err := store.UpdateDescriptions(ctx, []string{first.ID, second.ID}, &description); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []string{first.ID, second.ID} {
|
||||
node := mustGet(t, store, id)
|
||||
if node.Description == nil || *node.Description != description || node.Revision != 2 {
|
||||
t.Fatalf("description not updated: %#v", node)
|
||||
}
|
||||
}
|
||||
other := "should not be applied"
|
||||
if err := store.UpdateDescriptions(ctx, []string{first.ID, "missing"}, &other); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected not found, got %v", err)
|
||||
}
|
||||
if node := mustGet(t, store, first.ID); node.Description == nil || *node.Description != description {
|
||||
t.Fatalf("atomic update partially changed node: %#v", node)
|
||||
}
|
||||
if err := store.UpdateDescriptions(ctx, []string{first.ID, second.ID}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mustGet(t, store, first.ID).Description != nil || mustGet(t, store, second.ID).Description != nil {
|
||||
t.Fatal("descriptions were not cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
|
||||
Reference in new issue
Block a user