Preserve staged resource identities and expose upload receipts
This commit is contained in:
@@ -65,6 +65,18 @@ func (s *service) uploadAsset(w http.ResponseWriter, r *http.Request, project, a
|
||||
reject(w, 400, "invalid_idempotency_key")
|
||||
return nil
|
||||
}
|
||||
resourceID := strings.ToLower(r.Header.Get("X-Resource-Id"))
|
||||
if resourceID != "" && !uuidSlugPattern.MatchString(resourceID) {
|
||||
reject(w, 400, "invalid_resource_id")
|
||||
return nil
|
||||
}
|
||||
respond := func(info assetInfo) {
|
||||
if resourceID == "" {
|
||||
reply(w, 201, info)
|
||||
} else {
|
||||
reply(w, 201, response{"protocol_version": 2, "request_id": key, "status": "applied", "asset": info})
|
||||
}
|
||||
}
|
||||
tx, err := s.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -146,18 +158,28 @@ func (s *service) uploadAsset(w http.ResponseWriter, r *http.Request, project, a
|
||||
var previous string
|
||||
err = tx.QueryRowContext(r.Context(), `SELECT id::text,digest FROM canvas_assets WHERE project=$1 AND workspace_id=$2 AND uploaded_by=$3 AND idempotency_key=$4`, project, workspace, account, key).Scan(&info.ID, &previous)
|
||||
if err == nil {
|
||||
if previous != hash {
|
||||
if previous != hash || resourceID != "" && resourceID != info.ID {
|
||||
reject(w, 409, "idempotency_conflict")
|
||||
return nil
|
||||
}
|
||||
info.URL = s.assetURL(project, workspace, info.ID)
|
||||
reply(w, 201, info)
|
||||
respond(info)
|
||||
return nil
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
err = tx.QueryRowContext(r.Context(), `INSERT INTO canvas_assets(project,workspace_id,uploaded_by,mime_type,bytes,width,height,digest,idempotency_key) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id::text`, project, workspace, account, mime, encoded, width, height, hash, key).Scan(&info.ID)
|
||||
if resourceID != "" {
|
||||
var occupied bool
|
||||
if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM canvas_assets WHERE project=$1 AND workspace_id=$2 AND id=$3)`, project, workspace, resourceID).Scan(&occupied); err != nil {
|
||||
return err
|
||||
}
|
||||
if occupied {
|
||||
reject(w, 409, "resource_identity_conflict")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
err = tx.QueryRowContext(r.Context(), `INSERT INTO canvas_assets(project,workspace_id,uploaded_by,mime_type,bytes,width,height,digest,idempotency_key,id) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,COALESCE(NULLIF($10,'')::uuid,gen_random_uuid())) RETURNING id::text`, project, workspace, account, mime, encoded, width, height, hash, key, resourceID).Scan(&info.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -165,7 +187,40 @@ func (s *service) uploadAsset(w http.ResponseWriter, r *http.Request, project, a
|
||||
return err
|
||||
}
|
||||
info.URL = s.assetURL(project, workspace, info.ID)
|
||||
reply(w, 201, info)
|
||||
respond(info)
|
||||
return nil
|
||||
}
|
||||
|
||||
// A missing receipt is unknown: callers may only retry the same resource/key/bytes.
|
||||
func (s *service) assetOperation(w http.ResponseWriter, r *http.Request, project, account string) error {
|
||||
if r.Method != http.MethodGet {
|
||||
reject(w, 405, "method_not_allowed")
|
||||
return nil
|
||||
}
|
||||
key, workspace := r.PathValue("request"), r.PathValue("workspace")
|
||||
if !uuidSlugPattern.MatchString(key) {
|
||||
reject(w, 400, "invalid_idempotency_key")
|
||||
return nil
|
||||
}
|
||||
tx, err := s.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, _, err = lockWorkspace(r.Context(), tx, project, workspace, account); err != nil {
|
||||
return err
|
||||
}
|
||||
var info assetInfo
|
||||
err = tx.QueryRowContext(r.Context(), `SELECT id::text,mime_type,octet_length(bytes),width,height FROM canvas_assets WHERE project=$1 AND workspace_id=$2 AND uploaded_by=$3 AND idempotency_key=$4`, project, workspace, account, key).Scan(&info.ID, &info.MIME, &info.Bytes, &info.Width, &info.Height)
|
||||
if err == sql.ErrNoRows {
|
||||
reply(w, 404, response{"protocol_version": 2, "request_id": key, "status": "unknown", "error": "asset_receipt_unknown"})
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info.URL = s.assetURL(project, workspace, info.ID)
|
||||
reply(w, 200, response{"protocol_version": 2, "request_id": key, "status": "applied", "asset": info})
|
||||
return nil
|
||||
}
|
||||
func assetReadable(r *http.Request, tx *sql.Tx, project, workspace, id, account string) (bool, error) {
|
||||
|
||||
@@ -50,7 +50,7 @@ func (s *service) canvasCapabilities(w http.ResponseWriter, r *http.Request, pro
|
||||
"edge_features": response{"appearance": true, "markers": true, "bezier_chain": true, "animation_configuration": true, "execution_events": true},
|
||||
"subgraphs": response{"create": true, "migrate": true, "undo": true, "members": true, "dissolve": true, "connections": true, "member_positions": true},
|
||||
"ai": ai,
|
||||
"assets": response{"previews": true, "preview_max_edge": 1024, "originals_preserved": true},
|
||||
"assets": response{"client_resource_ids": true, "receipt_protocol_version": 2, "previews": true, "preview_max_edge": 1024, "originals_preserved": true},
|
||||
"limits": response{"max_depth": 8, "max_nodes": 500, "max_edges": 2000, "max_frame_ports": 4000, "max_curve_knots": 64, "max_snapshot_bytes": canvasLimit, "max_active_tasks": 128, "max_run_steps": 64, "max_execution_depth": 32, "max_input_images": 4, "max_input_image_bytes": 20 << 20, "max_coordinate": 1000000},
|
||||
"retention": response{"idempotency_min_seconds": 86400, "undo_receipt_seconds": 604800},
|
||||
})
|
||||
|
||||
@@ -76,7 +76,7 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match, Idempotency-Key, X-Expected-Account")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match, Idempotency-Key, X-Expected-Account, X-Resource-Id")
|
||||
reply(w, 204, nil)
|
||||
return
|
||||
}
|
||||
@@ -150,6 +150,7 @@ func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string, origi
|
||||
}
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials", s.protected(callbacks, s.materials))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/assets", s.protected(callbacks, s.uploadAsset))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/assets/operations/{request}", s.protected(callbacks, s.assetOperation))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/assets/{asset}", s.protected(callbacks, s.getAsset))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}", s.protected(callbacks, s.material))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas", s.protected(callbacks, s.canvas))
|
||||
|
||||
@@ -495,7 +495,7 @@ func TestOnboardingAndSharing(t *testing.T) {
|
||||
if err := png.Encode(&assetPNG, image.NewRGBA(image.Rect(0, 0, 3, 2))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
upload := func(encoded []byte, key string, status int) response {
|
||||
upload := func(encoded []byte, key string, status int, resourceIDs ...string) response {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
@@ -509,6 +509,9 @@ func TestOnboardingAndSharing(t *testing.T) {
|
||||
request.Header.Set("Authorization", "Bearer "+tokens[0])
|
||||
request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
request.Header.Set("Idempotency-Key", key)
|
||||
if len(resourceIDs) > 0 {
|
||||
request.Header.Set("X-Resource-Id", resourceIDs[0])
|
||||
}
|
||||
result := httptest.NewRecorder()
|
||||
mux.ServeHTTP(result, request)
|
||||
if result.Code != status {
|
||||
@@ -525,6 +528,33 @@ func TestOnboardingAndSharing(t *testing.T) {
|
||||
if uploaded["id"] != retriedAsset["id"] || uploaded["mime_type"] != "image/png" {
|
||||
t.Fatal("asset idempotency or actual MIME detection")
|
||||
}
|
||||
// New uploads keep the resource identity allocated in the frontend durable cut.
|
||||
resourceID, resourceKey := modelTestID(9100), modelTestID(9101)
|
||||
managed := upload(assetPNG.Bytes(), resourceKey, 201, resourceID)
|
||||
if managed["protocol_version"] != float64(2) || managed["request_id"] != resourceKey || managed["asset"].(map[string]any)["id"] != resourceID {
|
||||
t.Fatal("upload did not retain client resource identity")
|
||||
}
|
||||
repeated := upload(assetPNG.Bytes(), resourceKey, 201, resourceID)
|
||||
if !modelEqual(managed, repeated) {
|
||||
t.Fatal("resource retry changed receipt")
|
||||
}
|
||||
lookup := call("GET", workspacePath+"/assets/operations/"+resourceKey, tokens[0], nil, 200)
|
||||
if lookup["status"] != "applied" || !modelEqual(lookup["asset"], managed["asset"]) {
|
||||
t.Fatal("lost upload acknowledgment cannot be recovered")
|
||||
}
|
||||
unknown := call("GET", workspacePath+"/assets/operations/"+modelTestID(9102), tokens[0], nil, 404)
|
||||
if unknown["status"] != "unknown" || unknown["terminal"] == true {
|
||||
t.Fatal("missing asset receipt misreported zero-write")
|
||||
}
|
||||
call("GET", workspacePath+"/assets/operations/"+resourceKey, tokens[2], nil, 404)
|
||||
upload(assetPNG.Bytes(), resourceKey, 409, modelTestID(9103))
|
||||
upload(assetPNG.Bytes(), modelTestID(9104), 409, resourceID)
|
||||
var alternatePNG bytes.Buffer
|
||||
if err := png.Encode(&alternatePNG, image.NewRGBA(image.Rect(0, 0, 4, 4))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
upload(alternatePNG.Bytes(), resourceKey, 409, resourceID)
|
||||
upload(assetPNG.Bytes(), modelTestID(9105), 400, "invalid-resource")
|
||||
download := func(token string, status int) {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest("GET", workspacePath+"/assets/"+uploaded["id"].(string), nil)
|
||||
|
||||
Reference in New Issue
Block a user