Browse code

move endpoint API version constraints to API server

This introduces a `WithMinimumAPIVersion` RouteWrapper to configure the
minimum API version required for a route. It produces a 400 (Invalid Request)
error when accessing the endpoint on API versions lower than the given version.

Note that technically, it should produce a 404 ("not found") error,
as the endpoint should be considered "non-existing" on such API versions,
but 404 status-codes are used in business logic for various endpoints.

This patch allows removal of corresponding API-version checks from the client,
and other implementation of clients for the API. While the produced error message
is slightly more "technical", these situations should be rare and only happen
when the API version of the client is explicitly overridden, or a client was
implemented with a fixed API version (potentially missing version checks).

Before this patch, these errors were produced by the client:

DOCKER_API_VERSION=v1.24 docker container prune -f
docker container prune requires API version 1.25, but the Docker daemon API version is 1.24

With this patch applied, the error is returned by the daemon:

DOCKER_API_VERSION=v1.24 docker container prune -f
Error response from daemon: POST /containers/prune requires minimum API version 1.25

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>

Sebastiaan van Stijn authored on 2025/09/16 18:35:00
Showing 56 changed files
... ...
@@ -23,10 +23,6 @@ type BuildCachePruneOptions struct {
23 23
 
24 24
 // BuildCachePrune requests the daemon to delete unused cache data.
25 25
 func (cli *Client) BuildCachePrune(ctx context.Context, opts BuildCachePruneOptions) (*build.CachePruneReport, error) {
26
-	if err := cli.NewVersionError(ctx, "1.31", "build prune"); err != nil {
27
-		return nil, err
28
-	}
29
-
30 26
 	query := url.Values{}
31 27
 	if opts.All {
32 28
 		query.Set("all", "1")
... ...
@@ -9,16 +9,13 @@ import (
9 9
 
10 10
 // ConfigCreate creates a new config.
11 11
 func (cli *Client) ConfigCreate(ctx context.Context, config swarm.ConfigSpec) (swarm.ConfigCreateResponse, error) {
12
-	var response swarm.ConfigCreateResponse
13
-	if err := cli.NewVersionError(ctx, "1.30", "config create"); err != nil {
14
-		return response, err
15
-	}
16 12
 	resp, err := cli.post(ctx, "/configs/create", nil, config, nil)
17 13
 	defer ensureReaderClosed(resp)
18 14
 	if err != nil {
19
-		return response, err
15
+		return swarm.ConfigCreateResponse{}, err
20 16
 	}
21 17
 
18
+	var response swarm.ConfigCreateResponse
22 19
 	err = json.NewDecoder(resp.Body).Decode(&response)
23 20
 	return response, err
24 21
 }
... ...
@@ -16,17 +16,6 @@ import (
16 16
 	is "gotest.tools/v3/assert/cmp"
17 17
 )
18 18
 
19
-func TestConfigCreateUnsupported(t *testing.T) {
20
-	client, err := NewClientWithOpts(
21
-		WithVersion("1.29"),
22
-		WithHTTPClient(&http.Client{}),
23
-	)
24
-	assert.NilError(t, err)
25
-
26
-	_, err = client.ConfigCreate(context.Background(), swarm.ConfigSpec{})
27
-	assert.Check(t, is.Error(err, `"config create" requires API version 1.30, but the Docker daemon API version is 1.29`))
28
-}
29
-
30 19
 func TestConfigCreateError(t *testing.T) {
31 20
 	client, err := NewClientWithOpts(
32 21
 		WithVersion("1.30"),
... ...
@@ -15,9 +15,6 @@ func (cli *Client) ConfigInspectWithRaw(ctx context.Context, id string) (swarm.C
15 15
 	if err != nil {
16 16
 		return swarm.Config{}, nil, err
17 17
 	}
18
-	if err := cli.NewVersionError(ctx, "1.30", "config inspect"); err != nil {
19
-		return swarm.Config{}, nil, err
20
-	}
21 18
 	resp, err := cli.get(ctx, "/configs/"+id, nil, nil)
22 19
 	defer ensureReaderClosed(resp)
23 20
 	if err != nil {
... ...
@@ -43,17 +43,6 @@ func TestConfigInspectWithEmptyID(t *testing.T) {
43 43
 	assert.Check(t, is.ErrorContains(err, "value is empty"))
44 44
 }
45 45
 
46
-func TestConfigInspectUnsupported(t *testing.T) {
47
-	client, err := NewClientWithOpts(
48
-		WithVersion("1.29"),
49
-		WithHTTPClient(&http.Client{}),
50
-	)
51
-	assert.NilError(t, err)
52
-
53
-	_, _, err = client.ConfigInspectWithRaw(context.Background(), "nothing")
54
-	assert.Check(t, is.Error(err, `"config inspect" requires API version 1.30, but the Docker daemon API version is 1.29`))
55
-}
56
-
57 46
 func TestConfigInspectError(t *testing.T) {
58 47
 	client, err := NewClientWithOpts(
59 48
 		WithVersion("1.30"),
... ...
@@ -11,9 +11,6 @@ import (
11 11
 
12 12
 // ConfigList returns the list of configs.
13 13
 func (cli *Client) ConfigList(ctx context.Context, options ConfigListOptions) ([]swarm.Config, error) {
14
-	if err := cli.NewVersionError(ctx, "1.30", "config list"); err != nil {
15
-		return nil, err
16
-	}
17 14
 	query := url.Values{}
18 15
 
19 16
 	if options.Filters.Len() > 0 {
... ...
@@ -17,17 +17,6 @@ import (
17 17
 	is "gotest.tools/v3/assert/cmp"
18 18
 )
19 19
 
20
-func TestConfigListUnsupported(t *testing.T) {
21
-	client, err := NewClientWithOpts(
22
-		WithVersion("1.29"),
23
-		WithHTTPClient(&http.Client{}),
24
-	)
25
-	assert.NilError(t, err)
26
-
27
-	_, err = client.ConfigList(context.Background(), ConfigListOptions{})
28
-	assert.Check(t, is.Error(err, `"config list" requires API version 1.30, but the Docker daemon API version is 1.29`))
29
-}
30
-
31 20
 func TestConfigListError(t *testing.T) {
32 21
 	client, err := NewClientWithOpts(
33 22
 		WithVersion("1.30"),
... ...
@@ -8,9 +8,6 @@ func (cli *Client) ConfigRemove(ctx context.Context, id string) error {
8 8
 	if err != nil {
9 9
 		return err
10 10
 	}
11
-	if err := cli.NewVersionError(ctx, "1.30", "config remove"); err != nil {
12
-		return err
13
-	}
14 11
 	resp, err := cli.delete(ctx, "/configs/"+id, nil, nil)
15 12
 	defer ensureReaderClosed(resp)
16 13
 	return err
... ...
@@ -14,17 +14,6 @@ import (
14 14
 	is "gotest.tools/v3/assert/cmp"
15 15
 )
16 16
 
17
-func TestConfigRemoveUnsupported(t *testing.T) {
18
-	client, err := NewClientWithOpts(
19
-		WithVersion("1.29"),
20
-		WithHTTPClient(&http.Client{}),
21
-	)
22
-	assert.NilError(t, err)
23
-
24
-	err = client.ConfigRemove(context.Background(), "config_id")
25
-	assert.Check(t, is.Error(err, `"config remove" requires API version 1.30, but the Docker daemon API version is 1.29`))
26
-}
27
-
28 17
 func TestConfigRemoveError(t *testing.T) {
29 18
 	client, err := NewClientWithOpts(
30 19
 		WithVersion("1.30"),
... ...
@@ -13,9 +13,6 @@ func (cli *Client) ConfigUpdate(ctx context.Context, id string, version swarm.Ve
13 13
 	if err != nil {
14 14
 		return err
15 15
 	}
16
-	if err := cli.NewVersionError(ctx, "1.30", "config update"); err != nil {
17
-		return err
18
-	}
19 16
 	query := url.Values{}
20 17
 	query.Set("version", version.String())
21 18
 	resp, err := cli.post(ctx, "/configs/"+id+"/update", query, config, nil)
... ...
@@ -15,17 +15,6 @@ import (
15 15
 	is "gotest.tools/v3/assert/cmp"
16 16
 )
17 17
 
18
-func TestConfigUpdateUnsupported(t *testing.T) {
19
-	client, err := NewClientWithOpts(
20
-		WithVersion("1.29"),
21
-		WithHTTPClient(&http.Client{}),
22
-	)
23
-	assert.NilError(t, err)
24
-
25
-	err = client.ConfigUpdate(context.Background(), "config_id", swarm.Version{}, swarm.ConfigSpec{})
26
-	assert.Check(t, is.Error(err, `"config update" requires API version 1.30, but the Docker daemon API version is 1.29`))
27
-}
28
-
29 18
 func TestConfigUpdateError(t *testing.T) {
30 19
 	client, err := NewClientWithOpts(
31 20
 		WithVersion("1.30"),
... ...
@@ -11,10 +11,6 @@ import (
11 11
 
12 12
 // ContainersPrune requests the daemon to delete unused data
13 13
 func (cli *Client) ContainersPrune(ctx context.Context, pruneFilters filters.Args) (container.PruneReport, error) {
14
-	if err := cli.NewVersionError(ctx, "1.25", "container prune"); err != nil {
15
-		return container.PruneReport{}, err
16
-	}
17
-
18 14
 	query, err := getFiltersQuery(pruneFilters)
19 15
 	if err != nil {
20 16
 		return container.PruneReport{}, err
... ...
@@ -15,10 +15,6 @@ func (cli *Client) DistributionInspect(ctx context.Context, imageRef, encodedReg
15 15
 		return registry.DistributionInspect{}, objectNotFoundError{object: "distribution", id: imageRef}
16 16
 	}
17 17
 
18
-	if err := cli.NewVersionError(ctx, "1.30", "distribution inspect"); err != nil {
19
-		return registry.DistributionInspect{}, err
20
-	}
21
-
22 18
 	var headers http.Header
23 19
 	if encodedRegistryAuth != "" {
24 20
 		headers = http.Header{
... ...
@@ -11,13 +11,6 @@ import (
11 11
 	is "gotest.tools/v3/assert/cmp"
12 12
 )
13 13
 
14
-func TestDistributionInspectUnsupported(t *testing.T) {
15
-	client, err := NewClientWithOpts(WithVersion("1.29"), WithHTTPClient(&http.Client{}))
16
-	assert.NilError(t, err)
17
-	_, err = client.DistributionInspect(context.Background(), "foobar:1.0", "")
18
-	assert.Check(t, is.Error(err, `"distribution inspect" requires API version 1.30, but the Docker daemon API version is 1.29`))
19
-}
20
-
21 14
 func TestDistributionInspectWithEmptyID(t *testing.T) {
22 15
 	client, err := NewClientWithOpts(WithMockClient(func(req *http.Request) (*http.Response, error) {
23 16
 		return nil, errors.New("should not make request")
... ...
@@ -11,10 +11,6 @@ import (
11 11
 
12 12
 // ImagesPrune requests the daemon to delete unused data
13 13
 func (cli *Client) ImagesPrune(ctx context.Context, pruneFilters filters.Args) (image.PruneReport, error) {
14
-	if err := cli.NewVersionError(ctx, "1.25", "image prune"); err != nil {
15
-		return image.PruneReport{}, err
16
-	}
17
-
18 14
 	query, err := getFiltersQuery(pruneFilters)
19 15
 	if err != nil {
20 16
 		return image.PruneReport{}, err
... ...
@@ -11,10 +11,6 @@ import (
11 11
 
12 12
 // NetworksPrune requests the daemon to delete unused networks
13 13
 func (cli *Client) NetworksPrune(ctx context.Context, pruneFilters filters.Args) (network.PruneReport, error) {
14
-	if err := cli.NewVersionError(ctx, "1.25", "network prune"); err != nil {
15
-		return network.PruneReport{}, err
16
-	}
17
-
18 14
 	query, err := getFiltersQuery(pruneFilters)
19 15
 	if err != nil {
20 16
 		return network.PruneReport{}, err
... ...
@@ -19,9 +19,6 @@ func (cli *Client) PluginUpgrade(ctx context.Context, name string, options Plugi
19 19
 		return nil, err
20 20
 	}
21 21
 
22
-	if err := cli.NewVersionError(ctx, "1.26", "plugin upgrade"); err != nil {
23
-		return nil, err
24
-	}
25 22
 	query := url.Values{}
26 23
 	if _, err := reference.ParseNormalizedNamed(options.RemoteRef); err != nil {
27 24
 		return nil, fmt.Errorf("invalid remote reference: %w", err)
... ...
@@ -9,9 +9,6 @@ import (
9 9
 
10 10
 // SecretCreate creates a new secret.
11 11
 func (cli *Client) SecretCreate(ctx context.Context, secret swarm.SecretSpec) (swarm.SecretCreateResponse, error) {
12
-	if err := cli.NewVersionError(ctx, "1.25", "secret create"); err != nil {
13
-		return swarm.SecretCreateResponse{}, err
14
-	}
15 12
 	resp, err := cli.post(ctx, "/secrets/create", nil, secret, nil)
16 13
 	defer ensureReaderClosed(resp)
17 14
 	if err != nil {
... ...
@@ -16,13 +16,6 @@ import (
16 16
 	is "gotest.tools/v3/assert/cmp"
17 17
 )
18 18
 
19
-func TestSecretCreateUnsupported(t *testing.T) {
20
-	client, err := NewClientWithOpts(WithVersion("1.24"), WithHTTPClient(&http.Client{}))
21
-	assert.NilError(t, err)
22
-	_, err = client.SecretCreate(context.Background(), swarm.SecretSpec{})
23
-	assert.Check(t, is.Error(err, `"secret create" requires API version 1.25, but the Docker daemon API version is 1.24`))
24
-}
25
-
26 19
 func TestSecretCreateError(t *testing.T) {
27 20
 	client, err := NewClientWithOpts(WithVersion("1.25"), WithMockClient(errorMock(http.StatusInternalServerError, "Server error")))
28 21
 	assert.NilError(t, err)
... ...
@@ -15,9 +15,6 @@ func (cli *Client) SecretInspectWithRaw(ctx context.Context, id string) (swarm.S
15 15
 	if err != nil {
16 16
 		return swarm.Secret{}, nil, err
17 17
 	}
18
-	if err := cli.NewVersionError(ctx, "1.25", "secret inspect"); err != nil {
19
-		return swarm.Secret{}, nil, err
20
-	}
21 18
 	resp, err := cli.get(ctx, "/secrets/"+id, nil, nil)
22 19
 	defer ensureReaderClosed(resp)
23 20
 	if err != nil {
... ...
@@ -17,13 +17,6 @@ import (
17 17
 	is "gotest.tools/v3/assert/cmp"
18 18
 )
19 19
 
20
-func TestSecretInspectUnsupported(t *testing.T) {
21
-	client, err := NewClientWithOpts(WithVersion("1.24"), WithHTTPClient(&http.Client{}))
22
-	assert.NilError(t, err)
23
-	_, _, err = client.SecretInspectWithRaw(context.Background(), "nothing")
24
-	assert.Check(t, is.Error(err, `"secret inspect" requires API version 1.25, but the Docker daemon API version is 1.24`))
25
-}
26
-
27 20
 func TestSecretInspectError(t *testing.T) {
28 21
 	client, err := NewClientWithOpts(WithVersion("1.25"), WithMockClient(errorMock(http.StatusInternalServerError, "Server error")))
29 22
 	assert.NilError(t, err)
... ...
@@ -11,9 +11,6 @@ import (
11 11
 
12 12
 // SecretList returns the list of secrets.
13 13
 func (cli *Client) SecretList(ctx context.Context, options SecretListOptions) ([]swarm.Secret, error) {
14
-	if err := cli.NewVersionError(ctx, "1.25", "secret list"); err != nil {
15
-		return nil, err
16
-	}
17 14
 	query := url.Values{}
18 15
 
19 16
 	if options.Filters.Len() > 0 {
... ...
@@ -17,13 +17,6 @@ import (
17 17
 	is "gotest.tools/v3/assert/cmp"
18 18
 )
19 19
 
20
-func TestSecretListUnsupported(t *testing.T) {
21
-	client, err := NewClientWithOpts(WithVersion("1.24"), WithHTTPClient(&http.Client{}))
22
-	assert.NilError(t, err)
23
-	_, err = client.SecretList(context.Background(), SecretListOptions{})
24
-	assert.Check(t, is.Error(err, `"secret list" requires API version 1.25, but the Docker daemon API version is 1.24`))
25
-}
26
-
27 20
 func TestSecretListError(t *testing.T) {
28 21
 	client, err := NewClientWithOpts(WithVersion("1.25"), WithMockClient(errorMock(http.StatusInternalServerError, "Server error")))
29 22
 	assert.NilError(t, err)
... ...
@@ -8,9 +8,6 @@ func (cli *Client) SecretRemove(ctx context.Context, id string) error {
8 8
 	if err != nil {
9 9
 		return err
10 10
 	}
11
-	if err := cli.NewVersionError(ctx, "1.25", "secret remove"); err != nil {
12
-		return err
13
-	}
14 11
 	resp, err := cli.delete(ctx, "/secrets/"+id, nil, nil)
15 12
 	defer ensureReaderClosed(resp)
16 13
 	return err
... ...
@@ -14,13 +14,6 @@ import (
14 14
 	is "gotest.tools/v3/assert/cmp"
15 15
 )
16 16
 
17
-func TestSecretRemoveUnsupported(t *testing.T) {
18
-	client, err := NewClientWithOpts(WithVersion("1.24"), WithHTTPClient(&http.Client{}))
19
-	assert.NilError(t, err)
20
-	err = client.SecretRemove(context.Background(), "secret_id")
21
-	assert.Check(t, is.Error(err, `"secret remove" requires API version 1.25, but the Docker daemon API version is 1.24`))
22
-}
23
-
24 17
 func TestSecretRemoveError(t *testing.T) {
25 18
 	client, err := NewClientWithOpts(WithVersion("1.25"), WithMockClient(errorMock(http.StatusInternalServerError, "Server error")))
26 19
 	assert.NilError(t, err)
... ...
@@ -13,9 +13,6 @@ func (cli *Client) SecretUpdate(ctx context.Context, id string, version swarm.Ve
13 13
 	if err != nil {
14 14
 		return err
15 15
 	}
16
-	if err := cli.NewVersionError(ctx, "1.25", "secret update"); err != nil {
17
-		return err
18
-	}
19 16
 	query := url.Values{}
20 17
 	query.Set("version", version.String())
21 18
 	resp, err := cli.post(ctx, "/secrets/"+id+"/update", query, secret, nil)
... ...
@@ -15,13 +15,6 @@ import (
15 15
 	is "gotest.tools/v3/assert/cmp"
16 16
 )
17 17
 
18
-func TestSecretUpdateUnsupported(t *testing.T) {
19
-	client, err := NewClientWithOpts(WithVersion("1.24"), WithHTTPClient(&http.Client{}))
20
-	assert.NilError(t, err)
21
-	err = client.SecretUpdate(context.Background(), "secret_id", swarm.Version{}, swarm.SecretSpec{})
22
-	assert.Check(t, is.Error(err, `"secret update" requires API version 1.25, but the Docker daemon API version is 1.24`))
23
-}
24
-
25 18
 func TestSecretUpdateError(t *testing.T) {
26 19
 	client, err := NewClientWithOpts(WithVersion("1.25"), WithMockClient(errorMock(http.StatusInternalServerError, "Server error")))
27 20
 	assert.NilError(t, err)
... ...
@@ -11,10 +11,6 @@ import (
11 11
 
12 12
 // VolumesPrune requests the daemon to delete unused data
13 13
 func (cli *Client) VolumesPrune(ctx context.Context, pruneFilters filters.Args) (volume.PruneReport, error) {
14
-	if err := cli.NewVersionError(ctx, "1.25", "volume prune"); err != nil {
15
-		return volume.PruneReport{}, err
16
-	}
17
-
18 14
 	query, err := getFiltersQuery(pruneFilters)
19 15
 	if err != nil {
20 16
 		return volume.PruneReport{}, err
... ...
@@ -15,9 +15,6 @@ func (cli *Client) VolumeUpdate(ctx context.Context, volumeID string, version sw
15 15
 	if err != nil {
16 16
 		return err
17 17
 	}
18
-	if err := cli.NewVersionError(ctx, "1.42", "volume update"); err != nil {
19
-		return err
20
-	}
21 18
 
22 19
 	query := url.Values{}
23 20
 	query.Set("version", version.String())
... ...
@@ -32,7 +32,7 @@ func (br *buildRouter) Routes() []router.Route {
32 32
 func (br *buildRouter) initRoutes() {
33 33
 	br.routes = []router.Route{
34 34
 		router.NewPostRoute("/build", br.postBuild),
35
-		router.NewPostRoute("/build/prune", br.postPrune),
35
+		router.NewPostRoute("/build/prune", br.postPrune, router.WithMinimumAPIVersion("1.31")),
36 36
 		router.NewPostRoute("/build/cancel", br.postCancel),
37 37
 	}
38 38
 }
... ...
@@ -54,7 +54,7 @@ func (c *containerRouter) initRoutes() {
54 54
 		router.NewPostRoute("/exec/{name:.*}/resize", c.postContainerExecResize),
55 55
 		router.NewPostRoute("/containers/{name:.*}/rename", c.postContainerRename),
56 56
 		router.NewPostRoute("/containers/{name:.*}/update", c.postContainerUpdate),
57
-		router.NewPostRoute("/containers/prune", c.postContainersPrune),
57
+		router.NewPostRoute("/containers/prune", c.postContainersPrune, router.WithMinimumAPIVersion("1.25")),
58 58
 		router.NewPostRoute("/commit", c.postCommit),
59 59
 		// PUT
60 60
 		router.NewPutRoute("/containers/{name:.*}/archive", c.putContainersArchive),
... ...
@@ -26,6 +26,6 @@ func (dr *distributionRouter) Routes() []router.Route {
26 26
 func (dr *distributionRouter) initRoutes() {
27 27
 	dr.routes = []router.Route{
28 28
 		// GET
29
-		router.NewGetRoute("/distribution/{name:.*}/json", dr.getDistributionInfo),
29
+		router.NewGetRoute("/distribution/{name:.*}/json", dr.getDistributionInfo, router.WithMinimumAPIVersion("1.30")),
30 30
 	}
31 31
 }
... ...
@@ -41,7 +41,7 @@ func (ir *imageRouter) initRoutes() {
41 41
 		router.NewPostRoute("/images/create", ir.postImagesCreate),
42 42
 		router.NewPostRoute("/images/{name:.*}/push", ir.postImagesPush),
43 43
 		router.NewPostRoute("/images/{name:.*}/tag", ir.postImagesTag),
44
-		router.NewPostRoute("/images/prune", ir.postImagesPrune),
44
+		router.NewPostRoute("/images/prune", ir.postImagesPrune, router.WithMinimumAPIVersion("1.25")),
45 45
 		// DELETE
46 46
 		router.NewDeleteRoute("/images/{name:.*}", ir.deleteImages),
47 47
 	}
... ...
@@ -1,8 +1,10 @@
1 1
 package router
2 2
 
3 3
 import (
4
+	"context"
4 5
 	"net/http"
5 6
 
7
+	"github.com/moby/moby/api/types/versions"
6 8
 	"github.com/moby/moby/v2/daemon/server/httputils"
7 9
 )
8 10
 
... ...
@@ -71,3 +73,32 @@ func NewOptionsRoute(path string, handler httputils.APIFunc, opts ...RouteWrappe
71 71
 func NewHeadRoute(path string, handler httputils.APIFunc, opts ...RouteWrapper) Route {
72 72
 	return NewRoute(http.MethodHead, path, handler, opts...)
73 73
 }
74
+
75
+// WithMinimumAPIVersion configures the minimum API version required for
76
+// a route. It produces a 400 (Invalid Request) error when accessing the
77
+// endpoint on API versions lower than "minAPIVersion".
78
+//
79
+// Note that technically, it should produce a 404 ("not found") error,
80
+// as the endpoint should be considered "non-existing" on such API versions,
81
+// but 404 status-codes are used in business logic for various endpoints.
82
+func WithMinimumAPIVersion(minAPIVersion string) RouteWrapper {
83
+	return func(route Route) Route {
84
+		return localRoute{
85
+			method: route.Method(),
86
+			path:   route.Path(),
87
+			handler: func(ctx context.Context, w http.ResponseWriter, r *http.Request, vars map[string]string) error {
88
+				if v := httputils.VersionFromContext(ctx); v != "" && versions.LessThan(v, minAPIVersion) {
89
+					return versionError(route.Method() + " " + route.Path() + " requires minimum API version " + minAPIVersion)
90
+				}
91
+				return route.Handler()(ctx, w, r, vars)
92
+			},
93
+		}
94
+	}
95
+}
96
+
97
+type versionError string
98
+
99
+func (e versionError) Error() string {
100
+	return string(e)
101
+}
102
+func (e versionError) InvalidParameter() {}
... ...
@@ -36,7 +36,7 @@ func (n *networkRouter) initRoutes() {
36 36
 		router.NewPostRoute("/networks/create", n.postNetworkCreate),
37 37
 		router.NewPostRoute("/networks/{id:.*}/connect", n.postNetworkConnect),
38 38
 		router.NewPostRoute("/networks/{id:.*}/disconnect", n.postNetworkDisconnect),
39
-		router.NewPostRoute("/networks/prune", n.postNetworksPrune),
39
+		router.NewPostRoute("/networks/prune", n.postNetworksPrune, router.WithMinimumAPIVersion("1.25")),
40 40
 		// DELETE
41 41
 		router.NewDeleteRoute("/networks/{id:.*}", n.deleteNetwork),
42 42
 	}
... ...
@@ -32,7 +32,7 @@ func (pr *pluginRouter) initRoutes() {
32 32
 		router.NewPostRoute("/plugins/{name:.*}/disable", pr.disablePlugin),
33 33
 		router.NewPostRoute("/plugins/pull", pr.pullPlugin),
34 34
 		router.NewPostRoute("/plugins/{name:.*}/push", pr.pushPlugin),
35
-		router.NewPostRoute("/plugins/{name:.*}/upgrade", pr.upgradePlugin),
35
+		router.NewPostRoute("/plugins/{name:.*}/upgrade", pr.upgradePlugin, router.WithMinimumAPIVersion("1.26")),
36 36
 		router.NewPostRoute("/plugins/{name:.*}/set", pr.setPlugin),
37 37
 		router.NewPostRoute("/plugins/create", pr.createPlugin),
38 38
 	}
... ...
@@ -48,16 +48,16 @@ func (sr *swarmRouter) initRoutes() {
48 48
 		router.NewGetRoute("/tasks/{id}", sr.getTask),
49 49
 		router.NewGetRoute("/tasks/{id}/logs", sr.getTaskLogs),
50 50
 
51
-		router.NewGetRoute("/secrets", sr.getSecrets),
52
-		router.NewPostRoute("/secrets/create", sr.createSecret),
53
-		router.NewDeleteRoute("/secrets/{id}", sr.removeSecret),
54
-		router.NewGetRoute("/secrets/{id}", sr.getSecret),
55
-		router.NewPostRoute("/secrets/{id}/update", sr.updateSecret),
56
-
57
-		router.NewGetRoute("/configs", sr.getConfigs),
58
-		router.NewPostRoute("/configs/create", sr.createConfig),
59
-		router.NewDeleteRoute("/configs/{id}", sr.removeConfig),
60
-		router.NewGetRoute("/configs/{id}", sr.getConfig),
61
-		router.NewPostRoute("/configs/{id}/update", sr.updateConfig),
51
+		router.NewGetRoute("/secrets", sr.getSecrets, router.WithMinimumAPIVersion("1.25")),
52
+		router.NewPostRoute("/secrets/create", sr.createSecret, router.WithMinimumAPIVersion("1.25")),
53
+		router.NewDeleteRoute("/secrets/{id}", sr.removeSecret, router.WithMinimumAPIVersion("1.25")),
54
+		router.NewGetRoute("/secrets/{id}", sr.getSecret, router.WithMinimumAPIVersion("1.25")),
55
+		router.NewPostRoute("/secrets/{id}/update", sr.updateSecret, router.WithMinimumAPIVersion("1.25")),
56
+
57
+		router.NewGetRoute("/configs", sr.getConfigs, router.WithMinimumAPIVersion("1.30")),
58
+		router.NewPostRoute("/configs/create", sr.createConfig, router.WithMinimumAPIVersion("1.30")),
59
+		router.NewDeleteRoute("/configs/{id}", sr.removeConfig, router.WithMinimumAPIVersion("1.30")),
60
+		router.NewGetRoute("/configs/{id}", sr.getConfig, router.WithMinimumAPIVersion("1.30")),
61
+		router.NewPostRoute("/configs/{id}/update", sr.updateConfig, router.WithMinimumAPIVersion("1.30")),
62 62
 	}
63 63
 }
... ...
@@ -31,9 +31,9 @@ func (v *volumeRouter) initRoutes() {
31 31
 		router.NewGetRoute("/volumes/{name:.*}", v.getVolumeByName),
32 32
 		// POST
33 33
 		router.NewPostRoute("/volumes/create", v.postVolumesCreate),
34
-		router.NewPostRoute("/volumes/prune", v.postVolumesPrune),
34
+		router.NewPostRoute("/volumes/prune", v.postVolumesPrune, router.WithMinimumAPIVersion("1.25")),
35 35
 		// PUT
36
-		router.NewPutRoute("/volumes/{name:.*}", v.putVolumesUpdate),
36
+		router.NewPutRoute("/volumes/{name:.*}", v.putVolumesUpdate, router.WithMinimumAPIVersion("1.42")),
37 37
 		// DELETE
38 38
 		router.NewDeleteRoute("/volumes/{name:.*}", v.deleteVolumes),
39 39
 	}
... ...
@@ -23,10 +23,6 @@ type BuildCachePruneOptions struct {
23 23
 
24 24
 // BuildCachePrune requests the daemon to delete unused cache data.
25 25
 func (cli *Client) BuildCachePrune(ctx context.Context, opts BuildCachePruneOptions) (*build.CachePruneReport, error) {
26
-	if err := cli.NewVersionError(ctx, "1.31", "build prune"); err != nil {
27
-		return nil, err
28
-	}
29
-
30 26
 	query := url.Values{}
31 27
 	if opts.All {
32 28
 		query.Set("all", "1")
... ...
@@ -9,16 +9,13 @@ import (
9 9
 
10 10
 // ConfigCreate creates a new config.
11 11
 func (cli *Client) ConfigCreate(ctx context.Context, config swarm.ConfigSpec) (swarm.ConfigCreateResponse, error) {
12
-	var response swarm.ConfigCreateResponse
13
-	if err := cli.NewVersionError(ctx, "1.30", "config create"); err != nil {
14
-		return response, err
15
-	}
16 12
 	resp, err := cli.post(ctx, "/configs/create", nil, config, nil)
17 13
 	defer ensureReaderClosed(resp)
18 14
 	if err != nil {
19
-		return response, err
15
+		return swarm.ConfigCreateResponse{}, err
20 16
 	}
21 17
 
18
+	var response swarm.ConfigCreateResponse
22 19
 	err = json.NewDecoder(resp.Body).Decode(&response)
23 20
 	return response, err
24 21
 }
... ...
@@ -15,9 +15,6 @@ func (cli *Client) ConfigInspectWithRaw(ctx context.Context, id string) (swarm.C
15 15
 	if err != nil {
16 16
 		return swarm.Config{}, nil, err
17 17
 	}
18
-	if err := cli.NewVersionError(ctx, "1.30", "config inspect"); err != nil {
19
-		return swarm.Config{}, nil, err
20
-	}
21 18
 	resp, err := cli.get(ctx, "/configs/"+id, nil, nil)
22 19
 	defer ensureReaderClosed(resp)
23 20
 	if err != nil {
... ...
@@ -11,9 +11,6 @@ import (
11 11
 
12 12
 // ConfigList returns the list of configs.
13 13
 func (cli *Client) ConfigList(ctx context.Context, options ConfigListOptions) ([]swarm.Config, error) {
14
-	if err := cli.NewVersionError(ctx, "1.30", "config list"); err != nil {
15
-		return nil, err
16
-	}
17 14
 	query := url.Values{}
18 15
 
19 16
 	if options.Filters.Len() > 0 {
... ...
@@ -8,9 +8,6 @@ func (cli *Client) ConfigRemove(ctx context.Context, id string) error {
8 8
 	if err != nil {
9 9
 		return err
10 10
 	}
11
-	if err := cli.NewVersionError(ctx, "1.30", "config remove"); err != nil {
12
-		return err
13
-	}
14 11
 	resp, err := cli.delete(ctx, "/configs/"+id, nil, nil)
15 12
 	defer ensureReaderClosed(resp)
16 13
 	return err
... ...
@@ -13,9 +13,6 @@ func (cli *Client) ConfigUpdate(ctx context.Context, id string, version swarm.Ve
13 13
 	if err != nil {
14 14
 		return err
15 15
 	}
16
-	if err := cli.NewVersionError(ctx, "1.30", "config update"); err != nil {
17
-		return err
18
-	}
19 16
 	query := url.Values{}
20 17
 	query.Set("version", version.String())
21 18
 	resp, err := cli.post(ctx, "/configs/"+id+"/update", query, config, nil)
... ...
@@ -11,10 +11,6 @@ import (
11 11
 
12 12
 // ContainersPrune requests the daemon to delete unused data
13 13
 func (cli *Client) ContainersPrune(ctx context.Context, pruneFilters filters.Args) (container.PruneReport, error) {
14
-	if err := cli.NewVersionError(ctx, "1.25", "container prune"); err != nil {
15
-		return container.PruneReport{}, err
16
-	}
17
-
18 14
 	query, err := getFiltersQuery(pruneFilters)
19 15
 	if err != nil {
20 16
 		return container.PruneReport{}, err
... ...
@@ -15,10 +15,6 @@ func (cli *Client) DistributionInspect(ctx context.Context, imageRef, encodedReg
15 15
 		return registry.DistributionInspect{}, objectNotFoundError{object: "distribution", id: imageRef}
16 16
 	}
17 17
 
18
-	if err := cli.NewVersionError(ctx, "1.30", "distribution inspect"); err != nil {
19
-		return registry.DistributionInspect{}, err
20
-	}
21
-
22 18
 	var headers http.Header
23 19
 	if encodedRegistryAuth != "" {
24 20
 		headers = http.Header{
... ...
@@ -11,10 +11,6 @@ import (
11 11
 
12 12
 // ImagesPrune requests the daemon to delete unused data
13 13
 func (cli *Client) ImagesPrune(ctx context.Context, pruneFilters filters.Args) (image.PruneReport, error) {
14
-	if err := cli.NewVersionError(ctx, "1.25", "image prune"); err != nil {
15
-		return image.PruneReport{}, err
16
-	}
17
-
18 14
 	query, err := getFiltersQuery(pruneFilters)
19 15
 	if err != nil {
20 16
 		return image.PruneReport{}, err
... ...
@@ -11,10 +11,6 @@ import (
11 11
 
12 12
 // NetworksPrune requests the daemon to delete unused networks
13 13
 func (cli *Client) NetworksPrune(ctx context.Context, pruneFilters filters.Args) (network.PruneReport, error) {
14
-	if err := cli.NewVersionError(ctx, "1.25", "network prune"); err != nil {
15
-		return network.PruneReport{}, err
16
-	}
17
-
18 14
 	query, err := getFiltersQuery(pruneFilters)
19 15
 	if err != nil {
20 16
 		return network.PruneReport{}, err
... ...
@@ -19,9 +19,6 @@ func (cli *Client) PluginUpgrade(ctx context.Context, name string, options Plugi
19 19
 		return nil, err
20 20
 	}
21 21
 
22
-	if err := cli.NewVersionError(ctx, "1.26", "plugin upgrade"); err != nil {
23
-		return nil, err
24
-	}
25 22
 	query := url.Values{}
26 23
 	if _, err := reference.ParseNormalizedNamed(options.RemoteRef); err != nil {
27 24
 		return nil, fmt.Errorf("invalid remote reference: %w", err)
... ...
@@ -9,9 +9,6 @@ import (
9 9
 
10 10
 // SecretCreate creates a new secret.
11 11
 func (cli *Client) SecretCreate(ctx context.Context, secret swarm.SecretSpec) (swarm.SecretCreateResponse, error) {
12
-	if err := cli.NewVersionError(ctx, "1.25", "secret create"); err != nil {
13
-		return swarm.SecretCreateResponse{}, err
14
-	}
15 12
 	resp, err := cli.post(ctx, "/secrets/create", nil, secret, nil)
16 13
 	defer ensureReaderClosed(resp)
17 14
 	if err != nil {
... ...
@@ -15,9 +15,6 @@ func (cli *Client) SecretInspectWithRaw(ctx context.Context, id string) (swarm.S
15 15
 	if err != nil {
16 16
 		return swarm.Secret{}, nil, err
17 17
 	}
18
-	if err := cli.NewVersionError(ctx, "1.25", "secret inspect"); err != nil {
19
-		return swarm.Secret{}, nil, err
20
-	}
21 18
 	resp, err := cli.get(ctx, "/secrets/"+id, nil, nil)
22 19
 	defer ensureReaderClosed(resp)
23 20
 	if err != nil {
... ...
@@ -11,9 +11,6 @@ import (
11 11
 
12 12
 // SecretList returns the list of secrets.
13 13
 func (cli *Client) SecretList(ctx context.Context, options SecretListOptions) ([]swarm.Secret, error) {
14
-	if err := cli.NewVersionError(ctx, "1.25", "secret list"); err != nil {
15
-		return nil, err
16
-	}
17 14
 	query := url.Values{}
18 15
 
19 16
 	if options.Filters.Len() > 0 {
... ...
@@ -8,9 +8,6 @@ func (cli *Client) SecretRemove(ctx context.Context, id string) error {
8 8
 	if err != nil {
9 9
 		return err
10 10
 	}
11
-	if err := cli.NewVersionError(ctx, "1.25", "secret remove"); err != nil {
12
-		return err
13
-	}
14 11
 	resp, err := cli.delete(ctx, "/secrets/"+id, nil, nil)
15 12
 	defer ensureReaderClosed(resp)
16 13
 	return err
... ...
@@ -13,9 +13,6 @@ func (cli *Client) SecretUpdate(ctx context.Context, id string, version swarm.Ve
13 13
 	if err != nil {
14 14
 		return err
15 15
 	}
16
-	if err := cli.NewVersionError(ctx, "1.25", "secret update"); err != nil {
17
-		return err
18
-	}
19 16
 	query := url.Values{}
20 17
 	query.Set("version", version.String())
21 18
 	resp, err := cli.post(ctx, "/secrets/"+id+"/update", query, secret, nil)
... ...
@@ -11,10 +11,6 @@ import (
11 11
 
12 12
 // VolumesPrune requests the daemon to delete unused data
13 13
 func (cli *Client) VolumesPrune(ctx context.Context, pruneFilters filters.Args) (volume.PruneReport, error) {
14
-	if err := cli.NewVersionError(ctx, "1.25", "volume prune"); err != nil {
15
-		return volume.PruneReport{}, err
16
-	}
17
-
18 14
 	query, err := getFiltersQuery(pruneFilters)
19 15
 	if err != nil {
20 16
 		return volume.PruneReport{}, err
... ...
@@ -15,9 +15,6 @@ func (cli *Client) VolumeUpdate(ctx context.Context, volumeID string, version sw
15 15
 	if err != nil {
16 16
 		return err
17 17
 	}
18
-	if err := cli.NewVersionError(ctx, "1.42", "volume update"); err != nil {
19
-		return err
20
-	}
21 18
 
22 19
 	query := url.Values{}
23 20
 	query.Set("version", version.String())