Files
box-manifest-android/server/internal/httpapi/handler.go
T

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