mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 14:00:40 +00:00
90a304765f
The copy preflight decoded carried field values with plain json.Unmarshal into `any`, so a number above 2^53 became a float64 and the dry run previewed 9007199254740992 for a stored 9007199254740993. The copy itself keeps the stored digits (server-side), so the preview misled exactly where a user would check. Decode with models.DecodeJSONKeepingNumbers, as BUG-3448 did for item show and the MCP resource. The mutating copy's response is left on json.Unmarshal: it prints no carried values (the item's fields are a string), and a decode error there would misreport a committed copy. Tests: the client keeps the digits, and the rendered dry run through the real client shows them; on the old decode it renders "= 9007199254740992". Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
533 lines
23 KiB
Go
533 lines
23 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// Cross-workspace item copy — the CLI client half of PLAN-2357 / TASK-2366.
|
|
//
|
|
// Two endpoints, one request shape (deliberately — see the server's
|
|
// handlers_items_copy_preflight.go header):
|
|
//
|
|
// POST /workspaces/{ws}/items/{ref}/copy/preflight → ItemCopyPreflight
|
|
// POST /workspaces/{ws}/items/{ref}/copy → ItemCopyResult
|
|
//
|
|
// The response types below MIRROR internal/server's. To be clear about why,
|
|
// because the obvious guess is wrong: this is NOT an import cycle. Nothing
|
|
// in internal/server imports internal/cli, and this package could import
|
|
// server tomorrow — the mirror test below does exactly that.
|
|
//
|
|
// It is a deliberate layering choice, the same one internal/cli/bootstrap.go
|
|
// already records ("otherwise has no dependency on internal/server"): this
|
|
// package is the HTTP client, and a client that reaches into the server's
|
|
// package for its wire types stops being separable from it and starts
|
|
// linking the store, the migrations and the router into anything that wants
|
|
// to talk to a Pad API. Mirroring is also what cli.DeletedWorkspace and the
|
|
// bootstrap constants already do, so this follows the house pattern rather
|
|
// than inventing one.
|
|
//
|
|
// The cost of mirroring is drift, and that is paid for:
|
|
// TestItemCopyMirrorsMatchServerShapes lives in the external cli_test
|
|
// package (which CAN import server) and walks both shapes, so a server-side
|
|
// rename is a red build rather than a field that silently stops rendering.
|
|
//
|
|
// ── DR-13: THE MUTATING COPY IS NEVER RETRIED ────────────────────────────
|
|
//
|
|
// v1 has no idempotency key. A retry after a request that already committed
|
|
// creates a DUPLICATE item, and a caller who lost the response cannot tell
|
|
// which happened. CopyItem therefore:
|
|
//
|
|
// 1. runs on a DEDICATED *http.Client AND a dedicated transport
|
|
// (copyHTTPClient / copyTransport), so a retrying http.Client OR a
|
|
// retrying RoundTripper installed on the shared client cannot reach it.
|
|
// The transport half matters most: retry in Go is almost always a
|
|
// RoundTripper wrapper, which a merely-dedicated *http.Client would
|
|
// inherit;
|
|
// 2. sends a body net/http itself cannot replay — the reader is wrapped so
|
|
// Request.GetBody stays nil, which disables the transport's own
|
|
// "nothing was written, try the next connection" retry;
|
|
// 3. refuses redirects rather than re-issuing the POST at a new location;
|
|
// 4. reports an unrecoverable-outcome failure as ErrCopyOutcomeUnknown so
|
|
// the command layer can tell the user to CHECK the destination instead
|
|
// of re-running.
|
|
//
|
|
// TestCopyItem_* in client_items_copy_test.go guard all four. Do not route
|
|
// CopyItem through the shared post()/httpClient helpers.
|
|
|
|
// copyRequestTimeout bounds the mutating copy. It is deliberately longer
|
|
// than the shared client's 10s: a copy that clones many attachment rows and
|
|
// fans out activity/webhook writes can legitimately outlast a normal API
|
|
// call, and a client-side timeout is exactly the ambiguous outcome DR-13
|
|
// wants to make rare. It is still bounded — a hung connection must not
|
|
// wedge the CLI forever.
|
|
const copyRequestTimeout = 60 * time.Second
|
|
|
|
// ErrCopyOutcomeUnknown marks a mutating-copy failure where the copy may or
|
|
// may not have committed: any transport-level failure (timeout, reset, EOF)
|
|
// and the server's own 500 `copy_failed`. Test with CopyOutcomeUnknown.
|
|
var ErrCopyOutcomeUnknown = errors.New("the copy's outcome is unknown")
|
|
|
|
// ErrCopyCommitted marks a failure that happened AFTER the server confirmed
|
|
// the copy: a 2xx whose body could not be read, or could not be decoded.
|
|
// The copy HAPPENED; only the report is missing.
|
|
//
|
|
// It exists so the command layer can keep these off the non-zero exit path.
|
|
// A script that sees a failing exit code from `pad item copy` will
|
|
// reasonably conclude the copy did not happen, and the obvious recovery —
|
|
// running it again — is precisely the DR-13 duplicate. Test with
|
|
// CopyCommitted.
|
|
var ErrCopyCommitted = errors.New("the copy committed but its result could not be read")
|
|
|
|
// ItemCopyRequest is the wire body for BOTH endpoints.
|
|
type ItemCopyRequest struct {
|
|
// TargetWorkspace is the destination workspace slug (a UUID is also
|
|
// accepted server-side). Required.
|
|
TargetWorkspace string `json:"target_workspace"`
|
|
// TargetCollection is the destination collection slug. Required.
|
|
TargetCollection string `json:"target_collection"`
|
|
// FieldOverrides maps destination-schema field key → value. A key the
|
|
// destination schema does not declare is a 400; a null value UNSETS
|
|
// the key rather than persisting a literal null.
|
|
FieldOverrides map[string]any `json:"field_overrides,omitempty"`
|
|
// ArchiveSource is the MOVE path: copy, then archive the source.
|
|
ArchiveSource bool `json:"archive_source"`
|
|
}
|
|
|
|
// ItemCopyPreflightSource identifies the item being copied.
|
|
type ItemCopyPreflightSource struct {
|
|
WorkspaceSlug string `json:"workspace_slug"`
|
|
CollectionSlug string `json:"collection_slug"`
|
|
Ref string `json:"ref,omitempty"`
|
|
Slug string `json:"slug"`
|
|
Title string `json:"title"`
|
|
}
|
|
|
|
// ItemCopyPreflightDestination identifies where the copy would land.
|
|
type ItemCopyPreflightDestination struct {
|
|
WorkspaceSlug string `json:"workspace_slug"`
|
|
WorkspaceName string `json:"workspace_name"`
|
|
CollectionSlug string `json:"collection_slug"`
|
|
CollectionName string `json:"collection_name"`
|
|
}
|
|
|
|
// ItemCopyPreflightCarried is one field that survives to the destination.
|
|
type ItemCopyPreflightCarried struct {
|
|
Key string `json:"key"`
|
|
Label string `json:"label,omitempty"`
|
|
Type string `json:"type,omitempty"`
|
|
Value any `json:"value"`
|
|
// From is "migrated", "override" or "default".
|
|
From string `json:"from"`
|
|
}
|
|
|
|
// ItemCopyPreflightDropped is one value that will not be copied.
|
|
type ItemCopyPreflightDropped struct {
|
|
Key string `json:"key"`
|
|
Label string `json:"label,omitempty"`
|
|
// Kind is "field" or "assignment".
|
|
Kind string `json:"kind"`
|
|
// Reason is one of no_target_field / incompatible_type /
|
|
// undeclared_source_field / assignee_not_a_member /
|
|
// agent_role_not_portable / target_computed / not_unique, or one of the
|
|
// relation-referent reasons.
|
|
Reason string `json:"reason"`
|
|
// Detail is the sentence to print instead of the reason's generic one,
|
|
// when the server has something more specific to say: a not_unique drop
|
|
// names the value and, when visible, the item holding it (BUG-2367).
|
|
Detail string `json:"detail,omitempty"`
|
|
}
|
|
|
|
// ItemCopyPreflightNeedsValue is one destination field the copy cannot satisfy
|
|
// on its own.
|
|
//
|
|
// Usually the caller resolves it with an override. Not always: a row carrying
|
|
// `CollectionUnavailable` names a relation target that is deleted or unreadable
|
|
// by this caller, and a row with an EMPTY key cannot be named by `--field` — so
|
|
// the copy cannot proceed at all, and the CLI says so instead of advising a
|
|
// flag (IDEA-2899).
|
|
type ItemCopyPreflightNeedsValue struct {
|
|
Key string `json:"key"`
|
|
Label string `json:"label,omitempty"`
|
|
Type string `json:"type,omitempty"`
|
|
Options []string `json:"options,omitempty"`
|
|
// Collection is the target collection SLUG for a `relation` field, empty
|
|
// otherwise (TASK-2869). Mirrored here because
|
|
// TestItemCopyMirrorsMatchServerShapes requires this struct to match the
|
|
// server's field for field — a mirror that silently lags is a mirror that
|
|
// lies, and the CLI renders these rows.
|
|
Collection string `json:"collection,omitempty"`
|
|
// CollectionUnavailable is true when Collection names a target this caller
|
|
// cannot use — deleted, or not readable by them (IDEA-2899). Mirrored for
|
|
// the same reason Collection is, and the CLI uses it for the same decision
|
|
// the dialog does: a row whose target is unavailable must not appear in the
|
|
// `--field key=<value>` suggestion, because there is no value to supply.
|
|
//
|
|
// ABSENT means available OR a server that does not report; only an explicit
|
|
// true says the target is unusable.
|
|
CollectionUnavailable bool `json:"collection_unavailable,omitempty"`
|
|
Required bool `json:"required"`
|
|
// Reason is "missing_required" or "invalid_value".
|
|
Reason string `json:"reason"`
|
|
Message string `json:"message,omitempty"`
|
|
}
|
|
|
|
// ItemCopyPreflightFields is DR-15's bucketing. The three names are the
|
|
// contract; all three slices are always present server-side.
|
|
type ItemCopyPreflightFields struct {
|
|
Carried []ItemCopyPreflightCarried `json:"carried"`
|
|
Dropped []ItemCopyPreflightDropped `json:"dropped"`
|
|
NeedsValue []ItemCopyPreflightNeedsValue `json:"needs_value"`
|
|
}
|
|
|
|
// ItemCopyPreflightWarnings is DR-15's full warning set.
|
|
type ItemCopyPreflightWarnings struct {
|
|
ChildCount int `json:"child_count"`
|
|
ChildrenOrphaned bool `json:"children_orphaned"`
|
|
DroppedParent bool `json:"dropped_parent"`
|
|
OutgoingLinks map[string]int `json:"outgoing_links"`
|
|
IncomingLinks map[string]int `json:"incoming_links"`
|
|
DroppedAssignee bool `json:"dropped_assignee"`
|
|
DroppedAgentRole bool `json:"dropped_agent_role"`
|
|
AttachmentCount int `json:"attachment_count"`
|
|
AttachmentBytes int64 `json:"attachment_bytes"`
|
|
UnresolvableRefCount int `json:"unresolvable_ref_count"`
|
|
// RelationshipsPartial marks ChildCount, ChildrenOrphaned,
|
|
// DroppedParent, OutgoingLinks and IncomingLinks as a FLOOR rather
|
|
// than a total: at least one relationship hangs off an item this
|
|
// caller may not see and was not counted (TASK-2369). A bare bool by
|
|
// design — how many, of what type and where are exactly what the
|
|
// server's ACL filter withholds.
|
|
//
|
|
// ChildrenOrphaned is the exception when rendering: a plain copy
|
|
// archives nothing, so `false` is complete there however much is
|
|
// hidden. renderItemCopyPreflight qualifies that line only on a move.
|
|
RelationshipsPartial bool `json:"relationships_partial"`
|
|
}
|
|
|
|
// ItemCopyPreflight is the dry-run's 200 response.
|
|
type ItemCopyPreflight struct {
|
|
Source ItemCopyPreflightSource `json:"source"`
|
|
Destination ItemCopyPreflightDestination `json:"destination"`
|
|
ArchiveSource bool `json:"archive_source"`
|
|
// Valid means EXACTLY ONE THING: NeedsValue is empty. It is not a
|
|
// prediction that the copy will succeed.
|
|
Valid bool `json:"valid"`
|
|
Fields ItemCopyPreflightFields `json:"fields"`
|
|
Warnings ItemCopyPreflightWarnings `json:"warnings"`
|
|
}
|
|
|
|
// ItemCopyResultSource identifies what was copied, and whether it survived.
|
|
type ItemCopyResultSource struct {
|
|
WorkspaceSlug string `json:"workspace_slug"`
|
|
CollectionSlug string `json:"collection_slug"`
|
|
Ref string `json:"ref,omitempty"`
|
|
Slug string `json:"slug"`
|
|
Title string `json:"title"`
|
|
Archived bool `json:"archived"`
|
|
Seq int64 `json:"seq,omitempty"`
|
|
}
|
|
|
|
// ItemCopyResultDestination is where the copy landed.
|
|
type ItemCopyResultDestination struct {
|
|
WorkspaceSlug string `json:"workspace_slug"`
|
|
WorkspaceName string `json:"workspace_name"`
|
|
CollectionSlug string `json:"collection_slug"`
|
|
CollectionName string `json:"collection_name"`
|
|
Ref string `json:"ref,omitempty"`
|
|
Slug string `json:"slug"`
|
|
Seq int64 `json:"seq,omitempty"`
|
|
}
|
|
|
|
// ItemCopyResultWarnings is the after-the-fact counterpart to the
|
|
// preflight's warning block. Deliberately narrower — the relationship
|
|
// counters are preview-only.
|
|
type ItemCopyResultWarnings struct {
|
|
DroppedFields []string `json:"dropped_fields"`
|
|
// NotUnique says why and what for each dropped key that collided on a
|
|
// destination unique field (BUG-2367).
|
|
NotUnique []models.NotUniqueDrop `json:"not_unique,omitempty"`
|
|
DroppedAssignee bool `json:"dropped_assignee"`
|
|
DroppedAgentRole bool `json:"dropped_agent_role"`
|
|
AttachmentCount int `json:"attachment_count"`
|
|
AttachmentBytes int64 `json:"attachment_bytes"`
|
|
UnresolvableRefCount int `json:"unresolvable_ref_count"`
|
|
|
|
// SourceContentState says the body this copy carried was behind the
|
|
// source item's live collaborative document (BUG-3032). Omitted by the
|
|
// server when the source row was current.
|
|
SourceContentState string `json:"source_content_state,omitempty"`
|
|
}
|
|
|
|
// ItemCopyResult is the mutating copy's 201 response.
|
|
type ItemCopyResult struct {
|
|
Source ItemCopyResultSource `json:"source"`
|
|
Destination ItemCopyResultDestination `json:"destination"`
|
|
ArchiveSource bool `json:"archive_source"`
|
|
Item *models.Item `json:"item"`
|
|
Warnings ItemCopyResultWarnings `json:"warnings"`
|
|
}
|
|
|
|
// CopyItemPreflight runs the DRY RUN. It mutates nothing in the copy's own
|
|
// domain, so it is safe to call repeatedly and safe to retry.
|
|
//
|
|
// Returns the decoded preview AND the server's raw response bytes, so
|
|
// `--format json` can hand a script the endpoint's own contract rather than
|
|
// a CLI-shaped re-encoding of it.
|
|
func (c *Client) CopyItemPreflight(wsSlug, itemRef string, req ItemCopyRequest) (*ItemCopyPreflight, json.RawMessage, error) {
|
|
raw, err := c.postCopyJSON(c.httpClient, itemCopyPath(wsSlug, itemRef)+"/preflight", req, false)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
// Carried values are `any`, and plain json.Unmarshal turns a number into a
|
|
// float64, rounding one above 2^53. The dry run would then preview a value
|
|
// the copy itself never writes (the server keeps the stored digits).
|
|
// json.Number keeps them (BUG-3456, the BUG-3448 family).
|
|
var out ItemCopyPreflight
|
|
if err := models.DecodeJSONKeepingNumbers(raw, &out); err != nil {
|
|
return nil, raw, fmt.Errorf("decode copy preflight response: %w", err)
|
|
}
|
|
return &out, raw, nil
|
|
}
|
|
|
|
// CopyItem performs the MUTATING cross-workspace copy.
|
|
//
|
|
// NEVER RETRY THIS CALL (DR-13). See the file header for the four
|
|
// mechanisms that enforce it. On failure, check CopyOutcomeUnknown(err)
|
|
// before saying anything to the user about what happened.
|
|
func (c *Client) CopyItem(wsSlug, itemRef string, req ItemCopyRequest) (*ItemCopyResult, json.RawMessage, error) {
|
|
hc := c.copyHTTPClient()
|
|
// The transport is this call's own, so its idle connections have nobody
|
|
// to serve afterwards.
|
|
defer hc.CloseIdleConnections()
|
|
|
|
raw, err := c.postCopyJSON(hc, itemCopyPath(wsSlug, itemRef), req, true)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
var out ItemCopyResult
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
// The server answered 2xx, so the copy COMMITTED — we simply
|
|
// cannot render it. Not ErrCopyOutcomeUnknown: the outcome is
|
|
// known and it is "succeeded".
|
|
return nil, raw, fmt.Errorf("%w: decoding the response: %v", ErrCopyCommitted, err)
|
|
}
|
|
return &out, raw, nil
|
|
}
|
|
|
|
// itemCopyPath builds the copy endpoint's path with each dynamic segment
|
|
// escaped exactly once.
|
|
//
|
|
// Most of this client concatenates slugs into paths bare, and for the usual
|
|
// kebab-case slug or `TASK-5` ref that is indistinguishable from this. It is
|
|
// not good enough HERE: `..`, `/`, `?` and `#` in a ref would silently
|
|
// re-route or truncate the request, and this is the one endpoint in the CLI
|
|
// where landing on a DIFFERENT URL than intended could mutate the wrong
|
|
// thing. Escaping is a no-op for every legitimate ref, so it costs nothing
|
|
// and removes the class.
|
|
func itemCopyPath(wsSlug, itemRef string) string {
|
|
return "/workspaces/" + url.PathEscape(wsSlug) + "/items/" + url.PathEscape(itemRef) + "/copy"
|
|
}
|
|
|
|
// CopyCommitted reports whether err is a post-commit reporting failure —
|
|
// the copy happened, only its result was lost. Callers must not present
|
|
// these as failures of the copy, and must not exit non-zero on them.
|
|
func CopyCommitted(err error) bool {
|
|
return err != nil && errors.Is(err, ErrCopyCommitted)
|
|
}
|
|
|
|
// CopyOutcomeUnknown reports whether err leaves it genuinely unknown
|
|
// whether the copy committed. True for the server's 500 `copy_failed` and
|
|
// for any transport-level failure on the mutating call; false for every
|
|
// 4xx, which is a refusal the server made BEFORE writing anything.
|
|
func CopyOutcomeUnknown(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
if errors.Is(err, ErrCopyOutcomeUnknown) {
|
|
return true
|
|
}
|
|
var apiErr *APIError
|
|
if errors.As(err, &apiErr) {
|
|
return apiErr.Code == "copy_failed"
|
|
}
|
|
return false
|
|
}
|
|
|
|
// copyHTTPClient is the dedicated client for the mutating copy (DR-13
|
|
// mechanism 1): its own transport, its own timeout, and no redirect
|
|
// following.
|
|
//
|
|
// The caller must CloseIdleConnections when done — the transport is not
|
|
// shared, so its pool would otherwise outlive the call.
|
|
func (c *Client) copyHTTPClient() *http.Client {
|
|
return &http.Client{
|
|
Transport: c.copyTransport(),
|
|
Timeout: copyRequestTimeout,
|
|
// DR-13 mechanism 3. A 307/308 would otherwise re-send the POST
|
|
// body at a new URL — a retry by another name.
|
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
}
|
|
|
|
// copyTransport picks the RoundTripper the mutating copy runs on.
|
|
//
|
|
// A dedicated *http.Client is NOT enough on its own: retry behaviour in Go
|
|
// is usually implemented as a RoundTripper wrapper, and a wrapper installed
|
|
// on the shared client's Transport would be inherited by any client that
|
|
// reuses it. So the choice is made here, by type:
|
|
//
|
|
// - a plain *http.Transport is CLONED. Its only retry is the narrow
|
|
// nothing-written replay that mechanism 2 already disables, and cloning
|
|
// preserves whatever proxy, TLS and dialer configuration it carries.
|
|
// The clone brings its own connection pool, which costs one extra
|
|
// handshake per copy — an acceptable price, and the reason CopyItem
|
|
// closes idle connections afterwards.
|
|
//
|
|
// - anything else is a WRAPPER of unknown behaviour, which is exactly the
|
|
// shape a retrying RoundTripper takes. It is not used.
|
|
//
|
|
// The second branch has a real cost: a future auth- or tracing-wrapping
|
|
// RoundTripper would be bypassed here too, and this is the one call in the
|
|
// CLI where that could look like a mysterious connection failure. That is
|
|
// deliberate. DR-13's hazard is a duplicated item nobody can detect after
|
|
// the fact; a copy that visibly fails to connect is the better failure, and
|
|
// there is no way to tell the two kinds of wrapper apart from the outside.
|
|
// If a wrapper ever becomes load-bearing for this client, the fix is to
|
|
// give Client an explicit non-retrying transport field — not to start
|
|
// trusting c.httpClient.Transport here.
|
|
func (c *Client) copyTransport() http.RoundTripper {
|
|
base := c.httpClient.Transport
|
|
if base == nil {
|
|
base = http.DefaultTransport
|
|
}
|
|
if t, ok := base.(*http.Transport); ok {
|
|
return t.Clone()
|
|
}
|
|
if def, ok := http.DefaultTransport.(*http.Transport); ok {
|
|
return def.Clone()
|
|
}
|
|
return &http.Transport{}
|
|
}
|
|
|
|
// postCopyJSON posts body to path and returns the response's raw JSON.
|
|
//
|
|
// mutating selects the DR-13 posture: a mutating call gets a body net/http
|
|
// cannot replay and reports transport failures as ErrCopyOutcomeUnknown.
|
|
func (c *Client) postCopyJSON(hc *http.Client, path string, body any, mutating bool) (json.RawMessage, error) {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req, err := c.newCopyRequest(path, data, mutating)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := hc.Do(req)
|
|
if err != nil {
|
|
if mutating {
|
|
// DR-13 mechanism 4: no response reached us, so the copy may
|
|
// have committed anyway.
|
|
return nil, fmt.Errorf("%w: request failed: %v", ErrCopyOutcomeUnknown, err)
|
|
}
|
|
return nil, fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
raw, readErr := io.ReadAll(resp.Body)
|
|
if resp.StatusCode >= 400 {
|
|
return nil, parseErrorBody(resp.StatusCode, raw)
|
|
}
|
|
if resp.StatusCode >= 300 {
|
|
// Only reachable on the mutating path, where CheckRedirect hands
|
|
// the 3xx back instead of following it.
|
|
return nil, fmt.Errorf("unexpected redirect (%d) to %q; refusing to re-send the request", resp.StatusCode, resp.Header.Get("Location"))
|
|
}
|
|
if readErr != nil {
|
|
if mutating {
|
|
// A 2xx header already arrived, so the server committed. Only
|
|
// the body was lost — a reporting failure, not a copy failure.
|
|
return nil, fmt.Errorf("%w: reading the response body: %v", ErrCopyCommitted, readErr)
|
|
}
|
|
return nil, fmt.Errorf("read response: %w", readErr)
|
|
}
|
|
return json.RawMessage(raw), nil
|
|
}
|
|
|
|
// newCopyRequest builds the POST for either copy endpoint.
|
|
//
|
|
// mutating is DR-13 mechanism 2. net/http will REPLAY a request whose
|
|
// connection died before any byte was written — but only when it can rebuild
|
|
// the body, which means only when Request.GetBody is set. http.NewRequest
|
|
// sets GetBody automatically for the readers it recognises, *bytes.Reader
|
|
// among them.
|
|
//
|
|
// That replay is narrow and usually harmless. It is still not acceptable
|
|
// here: "nothing was written" is the TRANSPORT's belief about one connection
|
|
// attempt, and this command's contract to the user is that a copy leaves the
|
|
// process at most once. Hiding the reader behind an opaque type leaves
|
|
// GetBody nil, so the decision belongs to us rather than to a heuristic.
|
|
//
|
|
// The preflight is left replayable — it is read-only, and a lost connection
|
|
// there costs nothing.
|
|
//
|
|
// Extracted from postCopyJSON so TestCopyItem_MutatingRequestIsNotReplayable
|
|
// can assert the property directly. Asserting it end-to-end is not possible:
|
|
// provoking the transport's nothing-written path requires winning a race
|
|
// against an idle-connection close, so a network-level test would pass for
|
|
// the wrong reason.
|
|
func (c *Client) newCopyRequest(path string, data []byte, mutating bool) (*http.Request, error) {
|
|
var reader io.Reader = bytes.NewReader(data)
|
|
if mutating {
|
|
reader = &opaqueReader{r: reader}
|
|
}
|
|
req, err := c.newRequest(http.MethodPost, path, reader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// opaqueReader defeats net/http's length sniffing too, so restore the
|
|
// Content-Length the server would otherwise not see.
|
|
req.ContentLength = int64(len(data))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
return req, nil
|
|
}
|
|
|
|
// opaqueReader hides a reader's concrete type from http.NewRequest so that
|
|
// Request.GetBody stays nil. See newCopyRequest.
|
|
type opaqueReader struct{ r io.Reader }
|
|
|
|
func (o *opaqueReader) Read(p []byte) (int, error) { return o.r.Read(p) }
|
|
|
|
// PrintRawJSON writes a server response verbatim except for indentation.
|
|
//
|
|
// json.Indent is a LEXICAL transform: it does not decode, so key order,
|
|
// numeric literals (int64 byte counts and seq values especially) and every
|
|
// field the CLI does not model survive byte-for-byte. Re-encoding through a
|
|
// Go value would silently drop unknown fields and could round large
|
|
// integers through float64 — which is exactly what "the endpoint's response
|
|
// unchanged" is there to prevent.
|
|
func PrintRawJSON(w io.Writer, raw json.RawMessage) error {
|
|
var buf bytes.Buffer
|
|
if err := json.Indent(&buf, raw, "", " "); err != nil {
|
|
// Not valid JSON to indent — emit the bytes exactly as received
|
|
// rather than inventing a shape.
|
|
buf.Reset()
|
|
buf.Write(raw)
|
|
}
|
|
buf.WriteByte('\n')
|
|
_, err := w.Write(buf.Bytes())
|
|
return err
|
|
}
|