API Handlers should not error on empty request bodies

This is a Docker compatibility change discovered while working on
cases it is practically speaking required because there are
mandatory parameters in the body, but in those cases you do not
get a JSON decode error back, you get an error about the field
that needs to be set. I see no reason for us not to match this
convention; it doesn't break our existing bindings, but makes
using the API via curl or similar somewhat easier.

Signed-off-by: Matthew Heon <matthew.heon@pm.me>
This commit is contained in:
Matthew Heon 2026-05-13 14:09:38 -04:00
parent 82e7fc1440
commit 3ac6501fdd
15 changed files with 57 additions and 55 deletions

View file

@ -3,9 +3,7 @@
package compat
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
@ -22,9 +20,8 @@ import (
func Auth(w http.ResponseWriter, r *http.Request) {
var authConfig registry.AuthConfig
err := json.NewDecoder(r.Body).Decode(&authConfig)
if err != nil {
utils.Error(w, http.StatusInternalServerError, fmt.Errorf("failed to parse request: %w", err))
if err := utils.ReadJSONFromBody(r, &authConfig); err != nil {
utils.Error(w, http.StatusBadRequest, err)
return
}

View file

@ -766,8 +766,8 @@ func UpdateContainer(w http.ResponseWriter, r *http.Request) {
}
options := new(container.UpdateConfig)
if err := json.NewDecoder(r.Body).Decode(options); err != nil {
utils.Error(w, http.StatusInternalServerError, fmt.Errorf("decoding request body: %w", err))
if err := utils.ReadJSONFromBody(r, &options); err != nil {
utils.Error(w, http.StatusBadRequest, err)
return
}

View file

@ -48,8 +48,8 @@ func CreateContainer(w http.ResponseWriter, r *http.Request) {
// compatible configuration
body := handlers.CreateContainerConfig{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
utils.Error(w, http.StatusInternalServerError, fmt.Errorf("decode(): %w", err))
if err := utils.ReadJSONFromBody(r, &body); err != nil {
utils.Error(w, http.StatusBadRequest, err)
return
}

View file

@ -3,7 +3,6 @@
package compat
import (
"encoding/json"
"errors"
"fmt"
"net/http"
@ -28,8 +27,8 @@ func ExecCreateHandler(w http.ResponseWriter, r *http.Request) {
runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime)
input := new(handlers.ExecCreateConfig)
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
utils.InternalServerError(w, fmt.Errorf("decoding request body as JSON: %w", err))
if err := utils.ReadJSONFromBody(r, &input); err != nil {
utils.Error(w, http.StatusBadRequest, err)
return
}
@ -142,11 +141,11 @@ func ExecStartHandler(w http.ResponseWriter, r *http.Request) {
// TODO: We should read/support Tty from here.
bodyParams := new(handlers.ExecStartConfig)
if err := json.NewDecoder(r.Body).Decode(&bodyParams); err != nil {
utils.Error(w, http.StatusBadRequest, fmt.Errorf("failed to decode parameters for %s: %w", r.URL.String(), err))
if err := utils.ReadJSONFromBody(r, &bodyParams); err != nil {
utils.Error(w, http.StatusBadRequest, err)
return
}
// TODO: Verify TTY setting against what inspect session was made with
sessionCtr, err := runtime.GetExecSessionContainer(sessionID)
@ -219,9 +218,8 @@ func ExecRemoveHandler(w http.ResponseWriter, r *http.Request) {
sessionID := mux.Vars(r)["id"]
bodyParams := new(handlers.ExecRemoveConfig)
if err := json.NewDecoder(r.Body).Decode(&bodyParams); err != nil {
utils.Error(w, http.StatusBadRequest, fmt.Errorf("failed to decode parameters for %s: %w", r.URL.String(), err))
if err := utils.ReadJSONFromBody(r, &bodyParams); err != nil {
utils.Error(w, http.StatusBadRequest, err)
return
}

View file

@ -3,7 +3,6 @@
package compat
import (
"encoding/json"
"errors"
"fmt"
"net"
@ -210,8 +209,9 @@ func CreateNetwork(w http.ResponseWriter, r *http.Request) {
responseWarning string
)
runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime)
if err := json.NewDecoder(r.Body).Decode(&networkCreate); err != nil {
utils.Error(w, http.StatusInternalServerError, fmt.Errorf("Decode(): %w", err))
if err := utils.ReadJSONFromBody(r, &networkCreate); err != nil {
utils.Error(w, http.StatusBadRequest, err)
return
}
@ -376,8 +376,8 @@ func Connect(w http.ResponseWriter, r *http.Request) {
runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime)
var netConnect dockerNetwork.ConnectRequest
if err := json.NewDecoder(r.Body).Decode(&netConnect); err != nil {
utils.Error(w, http.StatusInternalServerError, fmt.Errorf("Decode(): %w", err))
if err := utils.ReadJSONFromBody(r, &netConnect); err != nil {
utils.Error(w, http.StatusBadRequest, err)
return
}
@ -441,8 +441,8 @@ func Disconnect(w http.ResponseWriter, r *http.Request) {
runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime)
var netDisconnect dockerNetwork.DisconnectRequest
if err := json.NewDecoder(r.Body).Decode(&netDisconnect); err != nil {
utils.Error(w, http.StatusInternalServerError, fmt.Errorf("Decode(): %w", err))
if err := utils.ReadJSONFromBody(r, &netDisconnect); err != nil {
utils.Error(w, http.StatusBadRequest, err)
return
}

View file

@ -5,7 +5,6 @@ package compat
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
@ -123,8 +122,8 @@ func CreateSecret(w http.ResponseWriter, r *http.Request) {
Labels map[string]string `schema:"labels"`
}{}
if err := json.NewDecoder(r.Body).Decode(&createParams); err != nil {
utils.Error(w, http.StatusInternalServerError, fmt.Errorf("Decode(): %w", err))
if err := utils.ReadJSONFromBody(r, &createParams); err != nil {
utils.Error(w, http.StatusBadRequest, err)
return
}

View file

@ -4,7 +4,6 @@ package compat
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
@ -108,8 +107,8 @@ func CreateVolume(w http.ResponseWriter, r *http.Request) {
}
// decode params from body
input := client.VolumeCreateOptions{}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
utils.Error(w, http.StatusInternalServerError, fmt.Errorf("Decode(): %w", err))
if err := utils.ReadJSONFromBody(r, &input); err != nil {
utils.Error(w, http.StatusBadRequest, err)
return
}

View file

@ -496,8 +496,8 @@ func UpdateContainer(w http.ResponseWriter, r *http.Request) {
}
wire := updateEntitiesWire{}
if err := json.NewDecoder(r.Body).Decode(&wire); err != nil {
utils.InternalServerError(w, fmt.Errorf("decode(): %w", err))
if err := utils.ReadJSONFromBody(r, &wire); err != nil {
utils.Error(w, http.StatusBadRequest, err)
return
}

View file

@ -3,7 +3,6 @@
package libpod
import (
"encoding/json"
"errors"
"fmt"
"net/http"
@ -62,8 +61,8 @@ func CreateContainer(w http.ResponseWriter, r *http.Request) {
},
}
if err := json.NewDecoder(r.Body).Decode(&wire); err != nil {
utils.Error(w, http.StatusInternalServerError, fmt.Errorf("decode(): %w", err))
if err := utils.ReadJSONFromBody(r, &wire); err != nil {
utils.Error(w, http.StatusBadRequest, err)
return
}

View file

@ -212,8 +212,8 @@ func ManifestAddV3(w http.ResponseWriter, r *http.Request) {
entities.ManifestAddOptions
TLSVerify bool `schema:"tlsVerify"`
}{}
if err := json.NewDecoder(r.Body).Decode(&query); err != nil {
utils.Error(w, http.StatusInternalServerError, fmt.Errorf("decoding AddV3 query: %w", err))
if err := utils.ReadJSONFromBody(r, &query); err != nil {
utils.Error(w, http.StatusBadRequest, err)
return
}
@ -495,8 +495,8 @@ func ManifestModify(w http.ResponseWriter, r *http.Request) {
if err != nil {
multireader = nil
// not multipart - request is just encoded JSON, nothing else
if err := json.NewDecoder(r.Body).Decode(body); err != nil {
utils.Error(w, http.StatusInternalServerError, fmt.Errorf("decoding modify request: %w", err))
if err := utils.ReadJSONFromBody(r, &body); err != nil {
utils.Error(w, http.StatusBadRequest, err)
return
}
} else {

View file

@ -3,7 +3,6 @@
package libpod
import (
"encoding/json"
"errors"
"fmt"
"net/http"
@ -27,8 +26,8 @@ func CreateNetwork(w http.ResponseWriter, r *http.Request) {
runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime)
network := types.Network{}
if err := json.NewDecoder(r.Body).Decode(&network); err != nil {
utils.Error(w, http.StatusInternalServerError, fmt.Errorf("failed to decode request JSON payload: %w", err))
if err := utils.ReadJSONFromBody(r, &network); err != nil {
utils.Error(w, http.StatusBadRequest, err)
return
}
@ -59,8 +58,8 @@ func UpdateNetwork(w http.ResponseWriter, r *http.Request) {
ic := abi.ContainerEngine{Libpod: runtime}
networkUpdateOptions := entities.NetworkUpdateOptions{}
if err := json.NewDecoder(r.Body).Decode(&networkUpdateOptions); err != nil {
utils.Error(w, http.StatusBadRequest, fmt.Errorf("failed to decode request JSON payload: %w", err))
if err := utils.ReadJSONFromBody(r, &networkUpdateOptions); err != nil {
utils.Error(w, http.StatusBadRequest, err)
return
}
@ -174,8 +173,8 @@ func Connect(w http.ResponseWriter, r *http.Request) {
runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime)
var netConnect entities.NetworkConnectOptions
if err := json.NewDecoder(r.Body).Decode(&netConnect); err != nil {
utils.Error(w, http.StatusInternalServerError, fmt.Errorf("failed to decode request JSON payload: %w", err))
if err := utils.ReadJSONFromBody(r, &netConnect); err != nil {
utils.Error(w, http.StatusBadRequest, err)
return
}
name := utils.GetName(r)

View file

@ -35,8 +35,8 @@ func PodCreate(w http.ResponseWriter, r *http.Request) {
err error
)
psg := specgen.PodSpecGenerator{InfraContainerSpec: &specgen.SpecGenerator{}}
if err = json.NewDecoder(r.Body).Decode(&psg); err != nil {
utils.Error(w, http.StatusInternalServerError, fmt.Errorf("%v: %w", failedToDecodeSpecgen, err))
if err := utils.ReadJSONFromBody(r, &psg); err != nil {
utils.Error(w, http.StatusBadRequest, err)
return
}

View file

@ -3,7 +3,6 @@
package libpod
import (
"encoding/json"
"errors"
"fmt"
"maps"
@ -39,9 +38,8 @@ func CreateVolume(w http.ResponseWriter, r *http.Request) {
}
input := entities.VolumeCreateOptions{}
// decode params from body
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
utils.Error(w, http.StatusInternalServerError, fmt.Errorf("Decode(): %w", err))
if err := utils.ReadJSONFromBody(r, &input); err != nil {
utils.Error(w, http.StatusBadRequest, err)
return
}

View file

@ -152,6 +152,19 @@ func MarshalErrorSliceJSONIsEmpty(ptr unsafe.Pointer) bool {
return len(*((*[]error)(ptr))) == 0
}
// ReadJSONFromBody reads JSON from a request body into the given struct.
// If the body is not provided/is empty, this returns immediately with no modification to the given struct.
// This means that defaults will NOT be overwritten in this case.
func ReadJSONFromBody(r *http.Request, unmarshalTo any) error {
if r.Body == nil || r.ContentLength == 0 {
return nil
}
if err := json.NewDecoder(r.Body).Decode(unmarshalTo); err != nil {
return fmt.Errorf("decoding request body as JSON: %w", err)
}
return nil
}
// WriteJSON writes an interface value encoded as JSON to w
func WriteJSON(w http.ResponseWriter, code int, value any) {
// FIXME: we don't need to write the header in all/some circumstances.

View file

@ -46,10 +46,10 @@ t POST libpod/networks/create name="network4" \
.labels.zaq=val
# test for empty mask
t POST libpod/networks/create subnets='[{"subnet":"10.10.134.0"}]' 500 \
t POST libpod/networks/create subnets='[{"subnet":"10.10.134.0"}]' 400 \
.cause~'.*invalid CIDR address: 10.10.134.0'
# test for invalid mask
t POST libpod/networks/create subnets='[{"subnet":"10.10.134.0/65"}]' 500 \
t POST libpod/networks/create subnets='[{"subnet":"10.10.134.0/65"}]' 400 \
.cause~'.*invalid CIDR address: 10.10.134.0/65'
# network list