509 lines
14 KiB
Go
509 lines
14 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"box-manifest/server/internal/importer"
|
|
"box-manifest/server/internal/tree"
|
|
)
|
|
|
|
type Handler struct {
|
|
store *tree.Store
|
|
imports *importer.Service
|
|
}
|
|
|
|
func New(store *tree.Store) http.Handler {
|
|
h := &Handler{store: store, imports: importer.New(store)}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /api/tree", h.getTree)
|
|
mux.HandleFunc("GET /api/node-types", h.listNodeTypes)
|
|
mux.HandleFunc("POST /api/node-types", h.createNodeType)
|
|
mux.HandleFunc("PUT /api/node-types/{typeId}", h.updateNodeType)
|
|
mux.HandleFunc("DELETE /api/node-types/{typeId}", h.deleteNodeType)
|
|
mux.HandleFunc("GET /api/tags", h.listTags)
|
|
mux.HandleFunc("POST /api/tags", h.createTag)
|
|
mux.HandleFunc("PUT /api/tags/{tagId}", h.updateTag)
|
|
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("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)
|
|
mux.HandleFunc("POST /api/nodes/bulk/classification", h.classifyNodesBulk)
|
|
mux.HandleFunc("GET /api/nodes/{nodeId}", h.getNode)
|
|
mux.HandleFunc("GET /api/nodes/by-code/{lookupCode}", h.getNodeByLookupCode)
|
|
mux.HandleFunc("PATCH /api/nodes/{nodeId}", h.updateNode)
|
|
mux.HandleFunc("DELETE /api/nodes/{nodeId}", h.deleteNode)
|
|
mux.HandleFunc("POST /api/nodes/{nodeId}/move", h.moveNode)
|
|
mux.HandleFunc("POST /api/nodes/{nodeId}/individualize", h.individualizeNode)
|
|
mux.HandleFunc("PUT /api/nodes/{nodeId}/classification", h.classifyNode)
|
|
mux.HandleFunc("POST /api/imports/tree/preview", h.previewTreeImport)
|
|
mux.HandleFunc("POST /api/imports/tree/commit", h.commitTreeImport)
|
|
mux.HandleFunc("GET /n/{lookupCode}", h.openCanonicalNode)
|
|
return mux
|
|
}
|
|
|
|
type createNodeRequest struct {
|
|
ParentID *string `json:"parentId"`
|
|
Name string `json:"name"`
|
|
Description *string `json:"description"`
|
|
Quantity *int64 `json:"quantity"`
|
|
}
|
|
|
|
type createNodesBulkRequest struct {
|
|
ParentID string `json:"parentId"`
|
|
Names []string `json:"names"`
|
|
Description *string `json:"description"`
|
|
}
|
|
|
|
type createNodesBulkResponse struct {
|
|
Nodes []tree.Node `json:"nodes"`
|
|
}
|
|
|
|
type moveNodesBulkRequest struct {
|
|
NodeIDs []string `json:"nodeIds"`
|
|
TargetParentID string `json:"targetParentId"`
|
|
ChildHandling tree.ChildHandling `json:"childHandling"`
|
|
}
|
|
|
|
type moveNodesBulkResponse struct {
|
|
Nodes []tree.Node `json:"nodes"`
|
|
}
|
|
|
|
type deleteNodesBulkRequest struct {
|
|
NodeIDs []string `json:"nodeIds"`
|
|
}
|
|
|
|
type individualizeNodeRequest struct {
|
|
Names []string `json:"names"`
|
|
}
|
|
type individualizeNodeResponse struct {
|
|
Nodes []tree.Node `json:"nodes"`
|
|
}
|
|
type combineNodesRequest struct {
|
|
NodeIDs []string `json:"nodeIds"`
|
|
RetainedNodeID string `json:"retainedNodeId"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type nodeTypeRequest struct {
|
|
Name string `json:"name"`
|
|
Description *string `json:"description"`
|
|
IconKey *string `json:"iconKey"`
|
|
Color *string `json:"color"`
|
|
}
|
|
type tagRequest struct {
|
|
Name string `json:"name"`
|
|
Color *string `json:"color"`
|
|
}
|
|
type classificationRequest struct {
|
|
TypeID *string `json:"typeId"`
|
|
TagIDs []string `json:"tagIds"`
|
|
}
|
|
type bulkClassificationRequest struct {
|
|
NodeIDs []string `json:"nodeIds"`
|
|
SetType bool `json:"setType"`
|
|
TypeID *string `json:"typeId"`
|
|
AddTagIDs []string `json:"addTagIds"`
|
|
RemoveTagIDs []string `json:"removeTagIds"`
|
|
}
|
|
|
|
type previewTreeImportRequest struct {
|
|
ParentID string `json:"parentId"`
|
|
Manifest importer.Manifest `json:"manifest"`
|
|
}
|
|
|
|
type commitTreeImportRequest struct {
|
|
PlanID string `json:"planId"`
|
|
}
|
|
|
|
type commitTreeImportResponse struct {
|
|
Created importer.Summary `json:"created"`
|
|
}
|
|
|
|
type optionalInt64 struct {
|
|
Set bool
|
|
Value *int64
|
|
}
|
|
|
|
type optionalString struct {
|
|
Set bool
|
|
Value *string
|
|
}
|
|
|
|
func (o *optionalString) UnmarshalJSON(data []byte) error {
|
|
o.Set = true
|
|
if string(data) == "null" {
|
|
o.Value = nil
|
|
return nil
|
|
}
|
|
return json.Unmarshal(data, &o.Value)
|
|
}
|
|
|
|
func (o *optionalInt64) UnmarshalJSON(data []byte) error {
|
|
o.Set = true
|
|
if string(data) == "null" {
|
|
o.Value = nil
|
|
return nil
|
|
}
|
|
return json.Unmarshal(data, &o.Value)
|
|
}
|
|
|
|
type updateNodeRequest struct {
|
|
Name *string `json:"name"`
|
|
Description optionalString `json:"description"`
|
|
Quantity optionalInt64 `json:"quantity"`
|
|
}
|
|
|
|
type moveNodeRequest struct {
|
|
TargetParentID string `json:"targetParentId"`
|
|
ChildHandling tree.ChildHandling `json:"childHandling"`
|
|
}
|
|
|
|
type errorResponse struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
func (h *Handler) getTree(w http.ResponseWriter, r *http.Request) {
|
|
root, err := h.store.Tree(r.Context())
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, root)
|
|
}
|
|
|
|
func (h *Handler) listNodeTypes(w http.ResponseWriter, r *http.Request) {
|
|
values, err := h.store.ListNodeTypes(r.Context())
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, values)
|
|
}
|
|
func (h *Handler) createNodeType(w http.ResponseWriter, r *http.Request) {
|
|
var request nodeTypeRequest
|
|
if decodeJSON(r, &request) != nil {
|
|
writeError(w, tree.ErrInvalid)
|
|
return
|
|
}
|
|
value, err := h.store.CreateNodeType(r.Context(), request.Name, request.Description, request.IconKey, request.Color)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, value)
|
|
}
|
|
func (h *Handler) updateNodeType(w http.ResponseWriter, r *http.Request) {
|
|
var request nodeTypeRequest
|
|
if decodeJSON(r, &request) != nil {
|
|
writeError(w, tree.ErrInvalid)
|
|
return
|
|
}
|
|
value, err := h.store.UpdateNodeType(r.Context(), r.PathValue("typeId"), request.Name, request.Description, request.IconKey, request.Color)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, value)
|
|
}
|
|
func (h *Handler) deleteNodeType(w http.ResponseWriter, r *http.Request) {
|
|
if err := h.store.DeleteNodeType(r.Context(), r.PathValue("typeId")); err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
func (h *Handler) listTags(w http.ResponseWriter, r *http.Request) {
|
|
values, err := h.store.ListTags(r.Context())
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, values)
|
|
}
|
|
func (h *Handler) createTag(w http.ResponseWriter, r *http.Request) {
|
|
var request tagRequest
|
|
if decodeJSON(r, &request) != nil {
|
|
writeError(w, tree.ErrInvalid)
|
|
return
|
|
}
|
|
value, err := h.store.CreateTag(r.Context(), request.Name, request.Color)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, value)
|
|
}
|
|
func (h *Handler) updateTag(w http.ResponseWriter, r *http.Request) {
|
|
var request tagRequest
|
|
if decodeJSON(r, &request) != nil {
|
|
writeError(w, tree.ErrInvalid)
|
|
return
|
|
}
|
|
value, err := h.store.UpdateTag(r.Context(), r.PathValue("tagId"), request.Name, request.Color)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, value)
|
|
}
|
|
func (h *Handler) deleteTag(w http.ResponseWriter, r *http.Request) {
|
|
if err := h.store.DeleteTag(r.Context(), r.PathValue("tagId")); err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Handler) createNode(w http.ResponseWriter, r *http.Request) {
|
|
var request createNodeRequest
|
|
if err := decodeJSON(r, &request); err != nil {
|
|
writeError(w, tree.ErrInvalid)
|
|
return
|
|
}
|
|
node, err := h.store.Create(r.Context(), request.ParentID, request.Name, request.Description, request.Quantity)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, node)
|
|
}
|
|
|
|
func (h *Handler) createNodesBulk(w http.ResponseWriter, r *http.Request) {
|
|
var request createNodesBulkRequest
|
|
if err := decodeJSON(r, &request); err != nil || strings.TrimSpace(request.ParentID) == "" {
|
|
writeError(w, tree.ErrInvalid)
|
|
return
|
|
}
|
|
nodes, err := h.store.CreateMany(r.Context(), request.ParentID, request.Names, request.Description)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, createNodesBulkResponse{Nodes: nodes})
|
|
}
|
|
|
|
func (h *Handler) moveNodesBulk(w http.ResponseWriter, r *http.Request) {
|
|
var request moveNodesBulkRequest
|
|
if err := decodeJSON(r, &request); err != nil || strings.TrimSpace(request.TargetParentID) == "" {
|
|
writeError(w, tree.ErrInvalid)
|
|
return
|
|
}
|
|
nodes, err := h.store.MoveMany(
|
|
r.Context(), request.NodeIDs, request.TargetParentID, request.ChildHandling,
|
|
)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, moveNodesBulkResponse{Nodes: nodes})
|
|
}
|
|
|
|
func (h *Handler) deleteNodesBulk(w http.ResponseWriter, r *http.Request) {
|
|
var request deleteNodesBulkRequest
|
|
if err := decodeJSON(r, &request); err != nil {
|
|
writeError(w, tree.ErrInvalid)
|
|
return
|
|
}
|
|
if err := h.store.DeleteMany(r.Context(), request.NodeIDs); 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 {
|
|
writeError(w, tree.ErrInvalid)
|
|
return
|
|
}
|
|
nodes, err := h.store.Individualize(r.Context(), r.PathValue("nodeId"), request.Names)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, individualizeNodeResponse{Nodes: nodes})
|
|
}
|
|
|
|
func (h *Handler) combineNodes(w http.ResponseWriter, r *http.Request) {
|
|
var request combineNodesRequest
|
|
if err := decodeJSON(r, &request); err != nil {
|
|
writeError(w, tree.ErrInvalid)
|
|
return
|
|
}
|
|
node, err := h.store.Combine(r.Context(), request.NodeIDs, request.RetainedNodeID, request.Name)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, node)
|
|
}
|
|
|
|
func (h *Handler) classifyNode(w http.ResponseWriter, r *http.Request) {
|
|
var request classificationRequest
|
|
if err := decodeJSON(r, &request); err != nil {
|
|
writeError(w, tree.ErrInvalid)
|
|
return
|
|
}
|
|
node, err := h.store.SetClassification(r.Context(), r.PathValue("nodeId"), request.TypeID, request.TagIDs)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, node)
|
|
}
|
|
|
|
func (h *Handler) classifyNodesBulk(w http.ResponseWriter, r *http.Request) {
|
|
var request bulkClassificationRequest
|
|
if err := decodeJSON(r, &request); err != nil {
|
|
writeError(w, tree.ErrInvalid)
|
|
return
|
|
}
|
|
if err := h.store.ClassifyMany(r.Context(), request.NodeIDs, request.SetType, request.TypeID, request.AddTagIDs, request.RemoveTagIDs); err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Handler) previewTreeImport(w http.ResponseWriter, r *http.Request) {
|
|
var request previewTreeImportRequest
|
|
if err := decodeJSON(r, &request); err != nil || strings.TrimSpace(request.ParentID) == "" {
|
|
writeError(w, tree.ErrInvalid)
|
|
return
|
|
}
|
|
preview, err := h.imports.Preview(r.Context(), request.ParentID, request.Manifest)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, preview)
|
|
}
|
|
|
|
func (h *Handler) commitTreeImport(w http.ResponseWriter, r *http.Request) {
|
|
var request commitTreeImportRequest
|
|
if err := decodeJSON(r, &request); err != nil || strings.TrimSpace(request.PlanID) == "" {
|
|
writeError(w, tree.ErrInvalid)
|
|
return
|
|
}
|
|
summary, err := h.imports.Commit(r.Context(), request.PlanID)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, commitTreeImportResponse{Created: summary})
|
|
}
|
|
|
|
func (h *Handler) getNode(w http.ResponseWriter, r *http.Request) {
|
|
node, err := h.store.Get(r.Context(), r.PathValue("nodeId"))
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, node)
|
|
}
|
|
|
|
func (h *Handler) getNodeByLookupCode(w http.ResponseWriter, r *http.Request) {
|
|
node, err := h.store.GetByLookupCode(r.Context(), r.PathValue("lookupCode"))
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, node)
|
|
}
|
|
|
|
func (h *Handler) openCanonicalNode(w http.ResponseWriter, r *http.Request) {
|
|
node, err := h.store.GetByLookupCode(r.Context(), r.PathValue("lookupCode"))
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/?code="+node.LookupCode, http.StatusSeeOther)
|
|
}
|
|
|
|
func (h *Handler) updateNode(w http.ResponseWriter, r *http.Request) {
|
|
var request updateNodeRequest
|
|
if err := decodeJSON(r, &request); err != nil {
|
|
writeError(w, tree.ErrInvalid)
|
|
return
|
|
}
|
|
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,
|
|
})
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, node)
|
|
}
|
|
|
|
func (h *Handler) deleteNode(w http.ResponseWriter, r *http.Request) {
|
|
if err := h.store.Delete(r.Context(), r.PathValue("nodeId")); err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Handler) moveNode(w http.ResponseWriter, r *http.Request) {
|
|
var request moveNodeRequest
|
|
if err := decodeJSON(r, &request); err != nil || strings.TrimSpace(request.TargetParentID) == "" {
|
|
writeError(w, tree.ErrInvalid)
|
|
return
|
|
}
|
|
node, err := h.store.Move(
|
|
r.Context(),
|
|
r.PathValue("nodeId"),
|
|
request.TargetParentID,
|
|
request.ChildHandling,
|
|
)
|
|
if err != nil {
|
|
writeError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, node)
|
|
}
|
|
|
|
func decodeJSON(r *http.Request, target any) error {
|
|
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(target); err != nil {
|
|
return err
|
|
}
|
|
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
|
return errors.New("request body must contain exactly one JSON value")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, value any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(value)
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, err error) {
|
|
status := http.StatusInternalServerError
|
|
response := errorResponse{Code: "INTERNAL_ERROR", Message: "internal server error"}
|
|
switch {
|
|
case errors.Is(err, tree.ErrNotFound):
|
|
status = http.StatusNotFound
|
|
response = errorResponse{Code: "NOT_FOUND", Message: "node not found"}
|
|
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.ErrInvalid):
|
|
status = http.StatusBadRequest
|
|
response = errorResponse{Code: "INVALID_REQUEST", Message: "invalid request"}
|
|
}
|
|
writeJSON(w, status, response)
|
|
}
|