Repository: compose-spec/compose-go
Pull request #465 (2023-09-27 13:02:38)

introduce DecodeMapstructure to allow type to define custom decode logic
This introduces a reflexion-based mechanism, comparable to yaml/json `Unmarshaler` interface, for types to define their own logic to convert value into internal data. This allows to have a single implementation of data type normalization, vs one declared as a mapstructure hook + one declared as a interpolation type cast (even if those redirect to a central func).

This also will make it easier to convert those into plain `yaml.Unmarshaler` if/once we get the parse+merge logic implemented _before_ converting to go structs

note: only implemented in a few structs, a few more are eligible to this approach


Patch:
diff --git a/loader/interpolate.go b/loader/interpolate.go
index aae6dc3..655e58e 100644
--- a/loader/interpolate.go
+++ b/loader/interpolate.go
@@ -46,10 +46,6 @@ var interpolateTypeCastMapping = map[tree.Path]interp.Cast{
 	servicePath("deploy", "placement", "max_replicas_per_node"):    toInt,
 	servicePath("healthcheck", "retries"):                          toInt,
 	servicePath("healthcheck", "disable"):                          toBoolean,
-	servicePath("mem_limit"):                                       toUnitBytes,
-	servicePath("mem_reservation"):                                 toUnitBytes,
-	servicePath("memswap_limit"):                                   toUnitBytes,
-	servicePath("mem_swappiness"):                                  toUnitBytes,
 	servicePath("oom_kill_disable"):                                toBoolean,
 	servicePath("oom_score_adj"):                                   toInt64,
 	servicePath("pids_limit"):                                      toInt64,
@@ -58,16 +54,13 @@ var interpolateTypeCastMapping = map[tree.Path]interp.Cast{
 	servicePath("read_only"):                                       toBoolean,
 	servicePath("scale"):                                           toInt,
 	servicePath("secrets", tree.PathMatchList, "mode"):             toInt,
-	servicePath("shm_size"):                                        toUnitBytes,
 	servicePath("stdin_open"):                                      toBoolean,
-	servicePath("stop_grace_period"):                               toDuration,
 	servicePath("tty"):                                             toBoolean,
 	servicePath("ulimits", tree.PathMatchAll):                      toInt,
 	servicePath("ulimits", tree.PathMatchAll, "hard"):              toInt,
 	servicePath("ulimits", tree.PathMatchAll, "soft"):              toInt,
 	servicePath("volumes", tree.PathMatchList, "read_only"):        toBoolean,
 	servicePath("volumes", tree.PathMatchList, "volume", "nocopy"): toBoolean,
-	servicePath("volumes", tree.PathMatchList, "tmpfs", "size"):    toUnitBytes,
 	iPath("networks", tree.PathMatchAll, "external"):               toBoolean,
 	iPath("networks", tree.PathMatchAll, "internal"):               toBoolean,
 	iPath("networks", tree.PathMatchAll, "attachable"):             toBoolean,
@@ -93,14 +86,6 @@ func toInt64(value string) (interface{}, error) {
 	return strconv.ParseInt(value, 10, 64)
 }
 
-func toUnitBytes(value string) (interface{}, error) {
-	return transformSize(value)
-}
-
-func toDuration(value string) (interface{}, error) {
-	return transformStringToDuration(value)
-}
-
 func toFloat(value string) (interface{}, error) {
 	return strconv.ParseFloat(value, 64)
 }
diff --git a/loader/loader.go b/loader/loader.go
index d5e82bb..1799075 100644
--- a/loader/loader.go
+++ b/loader/loader.go
@@ -28,15 +28,12 @@ import (
 	"regexp"
 	"strconv"
 	"strings"
-	"time"
 
 	"github.com/compose-spec/compose-go/consts"
 	interp "github.com/compose-spec/compose-go/interpolation"
 	"github.com/compose-spec/compose-go/schema"
 	"github.com/compose-spec/compose-go/template"
 	"github.com/compose-spec/compose-go/types"
-	"github.com/docker/go-units"
-	"github.com/mattn/go-shellwords"
 	"github.com/mitchellh/mapstructure"
 	"github.com/pkg/errors"
 	"github.com/sirupsen/logrus"
@@ -586,7 +583,7 @@ func Transform(source interface{}, target interface{}, additionalTransformers ..
 	config := &mapstructure.DecoderConfig{
 		DecodeHook: mapstructure.ComposeDecodeHookFunc(
 			createTransformHook(additionalTransformers...),
-			mapstructure.StringToTimeDurationHookFunc()),
+			decoderHook),
 		Result:   target,
 		TagName:  "yaml",
 		Metadata: &data,
@@ -610,28 +607,20 @@ type Transformer struct {
 func createTransformHook(additionalTransformers ...Transformer) mapstructure.DecodeHookFuncType {
 	transforms := map[reflect.Type]func(interface{}) (interface{}, error){
 		reflect.TypeOf(types.External{}):                         transformExternal,
-		reflect.TypeOf(types.HealthCheckTest{}):                  transformHealthCheckTest,
-		reflect.TypeOf(types.ShellCommand{}):                     transformShellCommand,
-		reflect.TypeOf(types.StringList{}):                       transformStringList,
-		reflect.TypeOf(types.Options{}):                          transformMapStringString,
+		reflect.TypeOf(types.Options{}):                          transformOptions,
 		reflect.TypeOf(types.UlimitsConfig{}):                    transformUlimits,
-		reflect.TypeOf(types.UnitBytes(0)):                       transformSize,
 		reflect.TypeOf([]types.ServicePortConfig{}):              transformServicePort,
 		reflect.TypeOf(types.ServiceSecretConfig{}):              transformFileReferenceConfig,
 		reflect.TypeOf(types.ServiceConfigObjConfig{}):           transformFileReferenceConfig,
-		reflect.TypeOf(types.StringOrNumberList{}):               transformStringOrNumberList,
 		reflect.TypeOf(map[string]*types.ServiceNetworkConfig{}): transformServiceNetworkMap,
 		reflect.TypeOf(types.Mapping{}):                          transformMappingOrListFunc("=", false),
 		reflect.TypeOf(types.MappingWithEquals{}):                transformMappingOrListFunc("=", true),
-		reflect.TypeOf(types.Labels{}):                           transformMappingOrListFunc("=", false),
 		reflect.TypeOf(types.MappingWithColon{}):                 transformMappingOrListFunc(":", false),
 		reflect.TypeOf(types.HostsList{}):                        transformMappingOrListFunc(":", false),
 		reflect.TypeOf(types.ServiceVolumeConfig{}):              transformServiceVolumeConfig,
 		reflect.TypeOf(types.BuildConfig{}):                      transformBuildConfig,
-		reflect.TypeOf(types.Duration(0)):                        transformStringToDuration,
 		reflect.TypeOf(types.DependsOnConfig{}):                  transformDependsOnConfig,
 		reflect.TypeOf(types.ExtendsConfig{}):                    transformExtendsConfig,
-		reflect.TypeOf(types.DeviceRequest{}):                    transformServiceDeviceRequest,
 		reflect.TypeOf(types.SSHConfig{}):                        transformSSHConfig,
 		reflect.TypeOf(types.IncludeConfig{}):                    transformIncludeConfig,
 	}
@@ -1031,7 +1020,7 @@ func loadFileObjectConfig(name string, objType string, obj types.FileObjectConfi
 	return obj, nil
 }
 
-var transformMapStringString TransformerFunc = func(data interface{}) (interface{}, error) {
+var transformOptions TransformerFunc = func(data interface{}) (interface{}, error) {
 	switch value := data.(type) {
 	case map[string]interface{}:
 		return toMapStringString(value, false), nil
@@ -1094,35 +1083,6 @@ var transformServicePort TransformerFunc = func(data interface{}) (interface{},
 	}
 }
 
-var transformServiceDeviceRequest TransformerFunc = func(data interface{}) (interface{}, error) {
-	switch value := data.(type) {
-	case map[string]interface{}:
-		count, ok := value["count"]
-		if ok {
-			switch val := count.(type) {
-			case int:
-				return value, nil
-			case string:
-				if strings.ToLower(val) == "all" {
-					value["count"] = -1
-					return value, nil
-				}
-				i, err := strconv.ParseInt(val, 10, 64)
-				if err == nil {
-					value["count"] = i
-					return value, nil
-				}
-				return data, errors.Errorf("invalid string value for 'count' (the only value allowed is 'all' or a number)")
-			default:
-				return data, errors.Errorf("invalid type %T for device count", val)
-			}
-		}
-		return data, nil
-	default:
-		return data, errors.Errorf("invalid type %T for resource reservation", value)
-	}
-}
-
 var transformFileReferenceConfig TransformerFunc = func(data interface{}) (interface{}, error) {
 	switch value := data.(type) {
 	case string:
@@ -1258,26 +1218,6 @@ func ParseShortSSHSyntax(value string) ([]types.SSHKey, error) {
 	return result, nil
 }
 
-var transformStringOrNumberList TransformerFunc = func(value interface{}) (interface{}, error) {
-	list := value.([]interface{})
-	result := make([]string, len(list))
-	for i, item := range list {
-		result[i] = fmt.Sprint(item)
-	}
-	return result, nil
-}
-
-var transformStringList TransformerFunc = func(data interface{}) (interface{}, error) {
-	switch value := data.(type) {
-	case string:
-		return []string{value}, nil
-	case []interface{}:
-		return value, nil
-	default:
-		return data, errors.Errorf("invalid type %T for string list", value)
-	}
-}
-
 func transformMappingOrListFunc(sep string, allowNil bool) TransformerFunc {
 	return func(data interface{}) (interface{}, error) {
 		return transformMappingOrList(data, sep, allowNil)
@@ -1312,52 +1252,6 @@ func transformValueToMapEntry(value string, separator string, allowNil bool) (st
 	}
 }
 
-var transformShellCommand TransformerFunc = func(value interface{}) (interface{}, error) {
-	if str, ok := value.(string); ok {
-		return shellwords.Parse(str)
-	}
-	return value, nil
-}
-
-var transformHealthCheckTest TransformerFunc = func(data interface{}) (interface{}, error) {
-	switch value := data.(type) {
-	case string:
-		return append([]string{"CMD-SHELL"}, value), nil
-	case []interface{}:
-		return value, nil
-	default:
-		return value, errors.Errorf("invalid type %T for healthcheck.test", value)
-	}
-}
-
-var transformSize TransformerFunc = func(value interface{}) (interface{}, error) {
-	switch value := value.(type) {
-	case int:
-		return int64(value), nil
-	case int64, types.UnitBytes:
-		return value, nil
-	case string:
-		return units.RAMInBytes(value)
-	default:
-		return value, errors.Errorf("invalid type for size %T", value)
-	}
-}
-
-var transformStringToDuration TransformerFunc = func(value interface{}) (interface{}, error) {
-	switch value := value.(type) {
-	case string:
-		d, err := time.ParseDuration(value)
-		if err != nil {
-			return value, err
-		}
-		return types.Duration(d), nil
-	case types.Duration:
-		return value, nil
-	default:
-		return value, errors.Errorf("invalid type %T for duration", value)
-	}
-}
-
 func toMapStringString(value map[string]interface{}, allowNil bool) map[string]interface{} {
 	output := make(map[string]interface{})
 	for key, value := range value {
diff --git a/loader/mapstructure.go b/loader/mapstructure.go
new file mode 100644
index 0000000..97a2e39
--- /dev/null
+++ b/loader/mapstructure.go
@@ -0,0 +1,53 @@
+/*
+   Copyright 2020 The Compose Specification Authors.
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+package loader
+
+import "reflect"
+
+// comparable to yaml.Unmarshaler, decoder allow a type to define it's own custom logic to convert value
+// see https://github.com/mitchellh/mapstructure/pull/294
+type decoder interface {
+	DecodeMapstructure(interface{}) error
+}
+
+// see https://github.com/mitchellh/mapstructure/issues/115#issuecomment-735287466
+// adapted to support types derived from built-in types, as DecodeMapstructure would not be able to mutate internal
+// value, so need to invoke DecodeMapstructure defined by pointer to type
+func decoderHook(from reflect.Value, to reflect.Value) (interface{}, error) {
+	// If the destination implements the decoder interface
+	u, ok := to.Interface().(decoder)
+	if !ok {
+		// for non-struct types we need to invoke func (*type) DecodeMapstructure()
+		if to.CanAddr() {
+			pto := to.Addr()
+			u, ok = pto.Interface().(decoder)
+		}
+		if !ok {
+			return from.Interface(), nil
+		}
+	}
+	// If it is nil and a pointer, create and assign the target value first
+	if to.Type().Kind() == reflect.Ptr && to.IsNil() {
+		to.Set(reflect.New(to.Type().Elem()))
+		u = to.Interface().(decoder)
+	}
+	// Call the custom DecodeMapstructure method
+	if err := u.DecodeMapstructure(from.Interface()); err != nil {
+		return to.Interface(), err
+	}
+	return to.Interface(), nil
+}
diff --git a/types/bytes.go b/types/bytes.go
new file mode 100644
index 0000000..4c873cd
--- /dev/null
+++ b/types/bytes.go
@@ -0,0 +1,42 @@
+/*
+   Copyright 2020 The Compose Specification Authors.
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+package types
+
+import (
+	"fmt"
+
+	"github.com/docker/go-units"
+)
+
+// UnitBytes is the bytes type
+type UnitBytes int64
+
+// MarshalYAML makes UnitBytes implement yaml.Marshaller
+func (u UnitBytes) MarshalYAML() (interface{}, error) {
+	return fmt.Sprintf("%d", u), nil
+}
+
+// MarshalJSON makes UnitBytes implement json.Marshaler
+func (u UnitBytes) MarshalJSON() ([]byte, error) {
+	return []byte(fmt.Sprintf(`"%d"`, u)), nil
+}
+
+func (u *UnitBytes) DecodeMapstructure(value interface{}) error {
+	v, err := units.RAMInBytes(fmt.Sprint(value))
+	*u = UnitBytes(v)
+	return err
+}
diff --git a/types/command.go b/types/command.go
new file mode 100644
index 0000000..90d575d
--- /dev/null
+++ b/types/command.go
@@ -0,0 +1,86 @@
+/*
+   Copyright 2020 The Compose Specification Authors.
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+package types
+
+import "github.com/mattn/go-shellwords"
+
+// ShellCommand is a string or list of string args.
+//
+// When marshaled to YAML, nil command fields will be omitted if `omitempty`
+// is specified as a struct tag. Explicitly empty commands (i.e. `[]` or
+// empty string will serialize to an empty array (`[]`).
+//
+// When marshaled to JSON, the `omitempty` struct must NOT be specified.
+// If the command field is nil, it will be serialized as `null`.
+// Explicitly empty commands (i.e. `[]` or empty string) will serialize to
+// an empty array (`[]`).
+//
+// The distinction between nil and explicitly empty is important to distinguish
+// between an unset value and a provided, but empty, value, which should be
+// preserved so that it can override any base value (e.g. container entrypoint).
+//
+// The different semantics between YAML and JSON are due to limitations with
+// JSON marshaling + `omitempty` in the Go stdlib, while gopkg.in/yaml.v3 gives
+// us more flexibility via the yaml.IsZeroer interface.
+//
+// In the future, it might make sense to make fields of this type be
+// `*ShellCommand` to avoid this situation, but that would constitute a
+// breaking change.
+type ShellCommand []string
+
+// IsZero returns true if the slice is nil.
+//
+// Empty (but non-nil) slices are NOT considered zero values.
+func (s ShellCommand) IsZero() bool {
+	// we do NOT want len(s) == 0, ONLY explicitly nil
+	return s == nil
+}
+
+// MarshalYAML returns nil (which will be serialized as `null`) for nil slices
+// and delegates to the standard marshaller behavior otherwise.
+//
+// NOTE: Typically the nil case here is not hit because IsZero has already
+// short-circuited marshalling, but this ensures that the type serializes
+// accurately if the `omitempty` struct tag is omitted/forgotten.
+//
+// A similar MarshalJSON() implementation is not needed because the Go stdlib
+// already serializes nil slices to `null`, whereas gopkg.in/yaml.v3 by default
+// serializes nil slices to `[]`.
+func (s ShellCommand) MarshalYAML() (interface{}, error) {
+	if s == nil {
+		return nil, nil
+	}
+	return []string(s), nil
+}
+
+func (s *ShellCommand) DecodeMapstructure(value interface{}) error {
+	switch v := value.(type) {
+	case string:
+		cmd, err := shellwords.Parse(v)
+		if err != nil {
+			return err
+		}
+		*s = cmd
+	case []interface{}:
+		cmd := make([]string, len(v))
+		for i, s := range v {
+			cmd[i] = s.(string)
+		}
+		*s = cmd
+	}
+	return nil
+}
diff --git a/types/device.go b/types/device.go
new file mode 100644
index 0000000..81b4bea
--- /dev/null
+++ b/types/device.go
@@ -0,0 +1,53 @@
+/*
+   Copyright 2020 The Compose Specification Authors.
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+package types
+
+import (
+	"strconv"
+	"strings"
+
+	"github.com/pkg/errors"
+)
+
+type DeviceRequest struct {
+	Capabilities []string    `yaml:"capabilities,omitempty" json:"capabilities,omitempty"`
+	Driver       string      `yaml:"driver,omitempty" json:"driver,omitempty"`
+	Count        DeviceCount `yaml:"count,omitempty" json:"count,omitempty"`
+	IDs          []string    `yaml:"device_ids,omitempty" json:"device_ids,omitempty"`
+}
+
+type DeviceCount int64
+
+func (c *DeviceCount) DecodeMapstructure(value interface{}) error {
+	switch v := value.(type) {
+	case int:
+		*c = DeviceCount(v)
+	case string:
+		if strings.ToLower(v) == "all" {
+			*c = -1
+			return nil
+		}
+		i, err := strconv.ParseInt(v, 10, 64)
+		if err != nil {
+			return errors.Errorf("invalid value %q, the only value allowed is 'all' or a number", v)
+		}
+		*c = DeviceCount(i)
+	default:
+		return errors.Errorf("invalid type %T for device count", v)
+	}
+	return nil
+}
diff --git a/types/duration.go b/types/duration.go
new file mode 100644
index 0000000..95f562a
--- /dev/null
+++ b/types/duration.go
@@ -0,0 +1,60 @@
+/*
+   Copyright 2020 The Compose Specification Authors.
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+package types
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+	"time"
+)
+
+// Duration is a thin wrapper around time.Duration with improved JSON marshalling
+type Duration time.Duration
+
+func (d Duration) String() string {
+	return time.Duration(d).String()
+}
+
+func (d *Duration) DecodeMapstructure(value interface{}) error {
+	v, err := time.ParseDuration(fmt.Sprint(value))
+	if err != nil {
+		return err
+	}
+	*d = Duration(v)
+	return nil
+}
+
+// MarshalJSON makes Duration implement json.Marshaler
+func (d Duration) MarshalJSON() ([]byte, error) {
+	return json.Marshal(d.String())
+}
+
+// MarshalYAML makes Duration implement yaml.Marshaler
+func (d Duration) MarshalYAML() (interface{}, error) {
+	return d.String(), nil
+}
+
+func (d *Duration) UnmarshalJSON(b []byte) error {
+	s := strings.Trim(string(b), "\"")
+	timeDuration, err := time.ParseDuration(s)
+	if err != nil {
+		return err
+	}
+	*d = Duration(timeDuration)
+	return nil
+}
diff --git a/types/healthcheck.go b/types/healthcheck.go
new file mode 100644
index 0000000..1bbf5e9
--- /dev/null
+++ b/types/healthcheck.go
@@ -0,0 +1,53 @@
+/*
+   Copyright 2020 The Compose Specification Authors.
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+package types
+
+import (
+	"fmt"
+)
+
+// HealthCheckConfig the healthcheck configuration for a service
+type HealthCheckConfig struct {
+	Test          HealthCheckTest `yaml:"test,omitempty" json:"test,omitempty"`
+	Timeout       *Duration       `yaml:"timeout,omitempty" json:"timeout,omitempty"`
+	Interval      *Duration       `yaml:"interval,omitempty" json:"interval,omitempty"`
+	Retries       *uint64         `yaml:"retries,omitempty" json:"retries,omitempty"`
+	StartPeriod   *Duration       `yaml:"start_period,omitempty" json:"start_period,omitempty"`
+	StartInterval *Duration       `yaml:"start_interval,omitempty" json:"start_interval,omitempty"`
+	Disable       bool            `yaml:"disable,omitempty" json:"disable,omitempty"`
+
+	Extensions Extensions `yaml:"#extensions,inline" json:"-"`
+}
+
+// HealthCheckTest is the command run to test the health of a service
+type HealthCheckTest []string
+
+func (l *HealthCheckTest) DecodeMapstructure(value interface{}) error {
+	switch v := value.(type) {
+	case string:
+		*l = []string{"CMD-SHELL", v}
+	case []interface{}:
+		seq := make([]string, len(v))
+		for i, e := range v {
+			seq[i] = e.(string)
+		}
+		*l = seq
+	default:
+		return fmt.Errorf("unexpected value type %T for healthcheck.test", value)
+	}
+	return nil
+}
diff --git a/types/labels.go b/types/labels.go
new file mode 100644
index 0000000..f30699b
--- /dev/null
+++ b/types/labels.go
@@ -0,0 +1,80 @@
+/*
+   Copyright 2020 The Compose Specification Authors.
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+package types
+
+import (
+	"fmt"
+	"strings"
+)
+
+// Labels is a mapping type for labels
+type Labels map[string]string
+
+func (l Labels) Add(key, value string) Labels {
+	if l == nil {
+		l = Labels{}
+	}
+	l[key] = value
+	return l
+}
+
+func (l Labels) AsList() []string {
+	s := make([]string, len(l))
+	i := 0
+	for k, v := range l {
+		s[i] = fmt.Sprintf("%s=%s", k, v)
+		i++
+	}
+	return s
+}
+
+// label value can be a string | number | boolean | null (empty)
+func labelValue(e interface{}) string {
+	if e == nil {
+		return ""
+	}
+	switch v := e.(type) {
+	case string:
+		return v
+	default:
+		return fmt.Sprint(v)
+	}
+}
+
+func (l *Labels) DecodeMapstructure(value interface{}) error {
+	switch v := value.(type) {
+	case map[string]interface{}:
+		labels := make(map[string]string, len(v))
+		for k, e := range v {
+			labels[k] = labelValue(e)
+		}
+		*l = labels
+	case []interface{}:
+		labels := make(map[string]string, len(v))
+		for _, s := range v {
+			k, e, ok := strings.Cut(fmt.Sprint(s), "=")
+			if !ok {
+				return fmt.Errorf("invalid label %q", v)
+			}
+			labels[k] = labelValue(e)
+		}
+		*l = labels
+	default:
+		return fmt.Errorf("unexpected value type %T for labels", value)
+	}
+	return nil
+}
diff --git a/types/options.go b/types/options.go
new file mode 100644
index 0000000..7ae8579
--- /dev/null
+++ b/types/options.go
@@ -0,0 +1,46 @@
+/*
+   Copyright 2020 The Compose Specification Authors.
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+package types
+
+import (
+	"fmt"
+
+	"github.com/pkg/errors"
+)
+
+// Options is a mapping type for options we pass as-is to container runtime
+type Options map[string]string
+
+func (d *Options) DecodeMapstructure(value interface{}) error {
+	switch v := value.(type) {
+	case map[string]interface{}:
+		m := make(map[string]string)
+		for key, e := range v {
+			if e == nil {
+				m[key] = ""
+			} else {
+				m[key] = fmt.Sprint(e)
+			}
+		}
+		*d = m
+	case map[string]string:
+		*d = v
+	default:
+		return errors.Errorf("invalid type %T for options", value)
+	}
+	return nil
+}
diff --git a/types/stringOrList.go b/types/stringOrList.go
new file mode 100644
index 0000000..3d91ad2
--- /dev/null
+++ b/types/stringOrList.go
@@ -0,0 +1,61 @@
+/*
+   Copyright 2020 The Compose Specification Authors.
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+package types
+
+import (
+	"fmt"
+
+	"github.com/pkg/errors"
+)
+
+// StringList is a type for fields that can be a string or list of strings
+type StringList []string
+
+func (l *StringList) DecodeMapstructure(value interface{}) error {
+	switch v := value.(type) {
+	case string:
+		*l = []string{v}
+	case []interface{}:
+		list := make([]string, len(v))
+		for i, e := range v {
+			list[i] = e.(string)
+		}
+		*l = list
+	default:
+		return errors.Errorf("invalid type %T for string list", value)
+	}
+	return nil
+}
+
+// StringOrNumberList is a type for fields that can be a list of strings or numbers
+type StringOrNumberList []string
+
+func (l *StringOrNumberList) DecodeMapstructure(value interface{}) error {
+	switch v := value.(type) {
+	case string:
+		*l = []string{v}
+	case []interface{}:
+		list := make([]string, len(v))
+		for i, e := range v {
+			list[i] = fmt.Sprint(e)
+		}
+		*l = list
+	default:
+		return errors.Errorf("invalid type %T for string list", value)
+	}
+	return nil
+}
diff --git a/types/types.go b/types/types.go
index 0c8ffcb..0407462 100644
--- a/types/types.go
+++ b/types/types.go
@@ -21,47 +21,10 @@ import (
 	"fmt"
 	"sort"
 	"strings"
-	"time"
 
 	"github.com/docker/go-connections/nat"
 )
 
-// Duration is a thin wrapper around time.Duration with improved JSON marshalling
-type Duration time.Duration
-
-func (d Duration) String() string {
-	return time.Duration(d).String()
-}
-
-// ConvertDurationPtr converts a type defined Duration pointer to a time.Duration pointer with the same value.
-func ConvertDurationPtr(d *Duration) *time.Duration {
-	if d == nil {
-		return nil
-	}
-	res := time.Duration(*d)
-	return &res
-}
-
-// MarshalJSON makes Duration implement json.Marshaler
-func (d Duration) MarshalJSON() ([]byte, error) {
-	return json.Marshal(d.String())
-}
-
-// MarshalYAML makes Duration implement yaml.Marshaler
-func (d Duration) MarshalYAML() (interface{}, error) {
-	return d.String(), nil
-}
-
-func (d *Duration) UnmarshalJSON(b []byte) error {
-	s := strings.Trim(string(b), "\"")
-	timeDuration, err := time.ParseDuration(s)
-	if err != nil {
-		return err
-	}
-	*d = Duration(timeDuration)
-	return nil
-}
-
 // Services is a list of ServiceConfig
 type Services []ServiceConfig
 
@@ -364,62 +327,6 @@ type ThrottleDevice struct {
 	Extensions Extensions `yaml:"#extensions,inline" json:"-"`
 }
 
-// ShellCommand is a string or list of string args.
-//
-// When marshaled to YAML, nil command fields will be omitted if `omitempty`
-// is specified as a struct tag. Explicitly empty commands (i.e. `[]` or
-// empty string will serialize to an empty array (`[]`).
-//
-// When marshaled to JSON, the `omitempty` struct must NOT be specified.
-// If the command field is nil, it will be serialized as `null`.
-// Explicitly empty commands (i.e. `[]` or empty string) will serialize to
-// an empty array (`[]`).
-//
-// The distinction between nil and explicitly empty is important to distinguish
-// between an unset value and a provided, but empty, value, which should be
-// preserved so that it can override any base value (e.g. container entrypoint).
-//
-// The different semantics between YAML and JSON are due to limitations with
-// JSON marshaling + `omitempty` in the Go stdlib, while gopkg.in/yaml.v3 gives
-// us more flexibility via the yaml.IsZeroer interface.
-//
-// In the future, it might make sense to make fields of this type be
-// `*ShellCommand` to avoid this situation, but that would constitute a
-// breaking change.
-type ShellCommand []string
-
-// IsZero returns true if the slice is nil.
-//
-// Empty (but non-nil) slices are NOT considered zero values.
-func (s ShellCommand) IsZero() bool {
-	// we do NOT want len(s) == 0, ONLY explicitly nil
-	return s == nil
-}
-
-// MarshalYAML returns nil (which will be serialized as `null`) for nil slices
-// and delegates to the standard marshaller behavior otherwise.
-//
-// NOTE: Typically the nil case here is not hit because IsZero has already
-// short-circuited marshalling, but this ensures that the type serializes
-// accurately if the `omitempty` struct tag is omitted/forgotten.
-//
-// A similar MarshalJSON() implementation is not needed because the Go stdlib
-// already serializes nil slices to `null`, whereas gopkg.in/yaml.v3 by default
-// serializes nil slices to `[]`.
-func (s ShellCommand) MarshalYAML() (interface{}, error) {
-	if s == nil {
-		return nil, nil
-	}
-	return []string(s), nil
-}
-
-// StringList is a type for fields that can be a string or list of strings
-type StringList []string
-
-// StringOrNumberList is a type for fields that can be a list of strings or
-// numbers
-type StringOrNumberList []string
-
 // MappingWithEquals is a mapping type that can be converted from a list of
 // key[=value] strings.
 // For the key with an empty value (`key=`), the mapped value is set to a pointer to `""`.
@@ -535,20 +442,6 @@ func (m Mapping) Merge(o Mapping) Mapping {
 	return m
 }
 
-// Options is a mapping type for options we pass as-is to container runtime
-type Options map[string]string
-
-// Labels is a mapping type for labels
-type Labels map[string]string
-
-func (l Labels) Add(key, value string) Labels {
-	if l == nil {
-		l = Labels{}
-	}
-	l[key] = value
-	return l
-}
-
 type SSHKey struct {
 	ID   string
 	Path string
@@ -633,22 +526,6 @@ type DeployConfig struct {
 	Extensions Extensions `yaml:"#extensions,inline" json:"-"`
 }
 
-// HealthCheckConfig the healthcheck configuration for a service
-type HealthCheckConfig struct {
-	Test          HealthCheckTest `yaml:"test,omitempty" json:"test,omitempty"`
-	Timeout       *Duration       `yaml:"timeout,omitempty" json:"timeout,omitempty"`
-	Interval      *Duration       `yaml:"interval,omitempty" json:"interval,omitempty"`
-	Retries       *uint64         `yaml:"retries,omitempty" json:"retries,omitempty"`
-	StartPeriod   *Duration       `yaml:"start_period,omitempty" json:"start_period,omitempty"`
-	StartInterval *Duration       `yaml:"start_interval,omitempty" json:"start_interval,omitempty"`
-	Disable       bool            `yaml:"disable,omitempty" json:"disable,omitempty"`
-
-	Extensions Extensions `yaml:"#extensions,inline" json:"-"`
-}
-
-// HealthCheckTest is the command run to test the health of a service
-type HealthCheckTest []string
-
 // UpdateConfig the service update configuration
 type UpdateConfig struct {
 	Parallelism     *uint64  `yaml:"parallelism,omitempty" json:"parallelism,omitempty"`
@@ -681,13 +558,6 @@ type Resource struct {
 	Extensions Extensions `yaml:"#extensions,inline" json:"-"`
 }
 
-type DeviceRequest struct {
-	Capabilities []string `yaml:"capabilities,omitempty" json:"capabilities,omitempty"`
-	Driver       string   `yaml:"driver,omitempty" json:"driver,omitempty"`
-	Count        int64    `yaml:"count,omitempty" json:"count,omitempty"`
-	IDs          []string `yaml:"device_ids,omitempty" json:"device_ids,omitempty"`
-}
-
 // GenericResource represents a "user defined" resource which can
 // only be an integer (e.g: SSD=3) for a service
 type GenericResource struct {
@@ -707,19 +577,6 @@ type DiscreteGenericResource struct {
 	Extensions Extensions `yaml:"#extensions,inline" json:"-"`
 }
 
-// UnitBytes is the bytes type
-type UnitBytes int64
-
-// MarshalYAML makes UnitBytes implement yaml.Marshaller
-func (u UnitBytes) MarshalYAML() (interface{}, error) {
-	return fmt.Sprintf("%d", u), nil
-}
-
-// MarshalJSON makes UnitBytes implement json.Marshaler
-func (u UnitBytes) MarshalJSON() ([]byte, error) {
-	return []byte(fmt.Sprintf(`"%d"`, u)), nil
-}
-
 // RestartPolicy the service restart policy
 type RestartPolicy struct {
 	Condition   string    `yaml:"condition,omitempty" json:"condition,omitempty"`


Test patch:
diff --git a/loader/loader_test.go b/loader/loader_test.go
index 44ffc15..a3f4a17 100644
--- a/loader/loader_test.go
+++ b/loader/loader_test.go
@@ -2129,9 +2129,9 @@ services:
           devices:
             - driver: nvidia
               capabilities: [gpu]
-              count: somestring
+              count: some_string
 `)
-	assert.ErrorContains(t, err, "invalid string value for 'count' (the only value allowed is 'all' or a number)")
+	assert.ErrorContains(t, err, `invalid value "some_string", the only value allowed is 'all' or a number`)
 }
 
 func TestServicePullPolicy(t *testing.T) {
diff --git a/loader/mapstructure_test.go b/loader/mapstructure_test.go
new file mode 100644
index 0000000..319483f
--- /dev/null
+++ b/loader/mapstructure_test.go
@@ -0,0 +1,65 @@
+/*
+   Copyright 2020 The Compose Specification Authors.
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+package loader
+
+import (
+	"testing"
+
+	"github.com/compose-spec/compose-go/types"
+	"github.com/mitchellh/mapstructure"
+	"gotest.tools/v3/assert"
+)
+
+func TestDecodeMapStructure(t *testing.T) {
+	var target types.ServiceConfig
+	data := mapstructure.Metadata{}
+	config := &mapstructure.DecoderConfig{
+		Result:     &target,
+		TagName:    "yaml",
+		Metadata:   &data,
+		DecodeHook: mapstructure.ComposeDecodeHookFunc(decoderHook),
+	}
+	decoder, err := mapstructure.NewDecoder(config)
+	assert.NilError(t, err)
+	err = decoder.Decode(map[string]interface{}{
+		"mem_limit":         "640k",
+		"command":           "echo hello",
+		"stop_grace_period": "60s",
+		"labels": []interface{}{
+			"FOO=BAR",
+		},
+		"deploy": map[string]interface{}{
+			"labels": map[string]interface{}{
+				"FOO": "BAR",
+				"BAZ": nil,
+				"QIX": 2,
+				"ZOT": true,
+			},
+		},
+	})
+	assert.NilError(t, err)
+	assert.Equal(t, target.MemLimit, types.UnitBytes(640*1024))
+	assert.DeepEqual(t, target.Command, types.ShellCommand{"echo", "hello"})
+	assert.Equal(t, *target.StopGracePeriod, types.Duration(60_000_000_000))
+	assert.DeepEqual(t, target.Labels, types.Labels{"FOO": "BAR"})
+	assert.DeepEqual(t, target.Deploy.Labels, types.Labels{
+		"FOO": "BAR",
+		"BAZ": "",
+		"QIX": "2",
+		"ZOT": "true",
+	})
+}