diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 1b77f50..6538ca9 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "0.7.0"
+ ".": "0.8.0"
}
\ No newline at end of file
diff --git a/.stats.yml b/.stats.yml
index dbab236..377ee95 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
-configured_endpoints: 24
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fhypeman-8fded10e90df28c07b64a92d12d665d54749b9fc13c35520667637fc596957d9.yml
-openapi_spec_hash: 7374a732372bddf7f2c0b532b56ae3fb
-config_hash: 510018ffa6ad6a17875954f66fe69598
+configured_endpoints: 30
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fhypeman-28e78b73c796f9ee866671ed946402b5d569e683c3207d57c9143eb7d6f83fb6.yml
+openapi_spec_hash: fce0ac8713369a5f048bac684ed34fc8
+config_hash: f65a6a2bcef49a9f623212f9de6d6f6f
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8bef0d6..7b777d4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,25 @@
# Changelog
+## 0.8.0 (2025-12-23)
+
+Full Changelog: [v0.7.0...v0.8.0](https://github.com/onkernel/hypeman-go/compare/v0.7.0...v0.8.0)
+
+### Features
+
+* add hypeman cp for file copy to/from running VMs ([49ea898](https://github.com/onkernel/hypeman-go/commit/49ea89852eed5e0893febc4c68d295a0d1a8bfe5))
+* **encoder:** support bracket encoding form-data object members ([8ab31e8](https://github.com/onkernel/hypeman-go/commit/8ab31e89c70baa967842c1c160d0b49db44b089a))
+* gpu passthrough ([067a01b](https://github.com/onkernel/hypeman-go/commit/067a01b4ac06e82c2db6b165127144afa18a691d))
+
+
+### Bug Fixes
+
+* skip usage tests that don't work with Prism ([d62b246](https://github.com/onkernel/hypeman-go/commit/d62b2466715247e7d083ab7ef33040e5da036bd8))
+
+
+### Chores
+
+* add float64 to valid types for RegisterFieldValidator ([b4666fd](https://github.com/onkernel/hypeman-go/commit/b4666fd1bfcdd17b0a4d4bf88541670cd40c8b1c))
+
## 0.7.0 (2025-12-11)
Full Changelog: [v0.6.0...v0.7.0](https://github.com/onkernel/hypeman-go/compare/v0.6.0...v0.7.0)
diff --git a/README.md b/README.md
index e535d13..7f8b79a 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,7 @@ Or to pin the version:
```sh
-go get -u 'github.com/onkernel/hypeman-go@v0.7.0'
+go get -u 'github.com/onkernel/hypeman-go@v0.8.0'
```
diff --git a/api.md b/api.md
index 11cb961..6738d2c 100644
--- a/api.md
+++ b/api.md
@@ -30,6 +30,7 @@ Params Types:
Response Types:
- hypeman.Instance
+- hypeman.PathInfo
- hypeman.VolumeMount
Methods:
@@ -42,6 +43,7 @@ Methods:
- client.Instances.Restore(ctx context.Context, id string) (hypeman.Instance, error)
- client.Instances.Standby(ctx context.Context, id string) (hypeman.Instance, error)
- client.Instances.Start(ctx context.Context, id string) (hypeman.Instance, error)
+- client.Instances.Stat(ctx context.Context, id string, query hypeman.InstanceStatParams) (hypeman.PathInfo, error)
- client.Instances.Stop(ctx context.Context, id string) (hypeman.Instance, error)
## Volumes
@@ -65,6 +67,22 @@ Methods:
- client.Volumes.Delete(ctx context.Context, id string) error
- client.Volumes.Get(ctx context.Context, id string) (hypeman.Volume, error)
+# Devices
+
+Response Types:
+
+- hypeman.AvailableDevice
+- hypeman.Device
+- hypeman.DeviceType
+
+Methods:
+
+- client.Devices.New(ctx context.Context, body hypeman.DeviceNewParams) (hypeman.Device, error)
+- client.Devices.Get(ctx context.Context, id string) (hypeman.Device, error)
+- client.Devices.List(ctx context.Context) ([]hypeman.Device, error)
+- client.Devices.Delete(ctx context.Context, id string) error
+- client.Devices.ListAvailable(ctx context.Context) ([]hypeman.AvailableDevice, error)
+
# Ingresses
Params Types:
diff --git a/client.go b/client.go
index 70aefcc..69ad745 100644
--- a/client.go
+++ b/client.go
@@ -21,6 +21,7 @@ type Client struct {
Images ImageService
Instances InstanceService
Volumes VolumeService
+ Devices DeviceService
Ingresses IngressService
}
@@ -50,6 +51,7 @@ func NewClient(opts ...option.RequestOption) (r Client) {
r.Images = NewImageService(opts...)
r.Instances = NewInstanceService(opts...)
r.Volumes = NewVolumeService(opts...)
+ r.Devices = NewDeviceService(opts...)
r.Ingresses = NewIngressService(opts...)
return
diff --git a/device.go b/device.go
new file mode 100644
index 0000000..1a1d3b7
--- /dev/null
+++ b/device.go
@@ -0,0 +1,198 @@
+// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+package hypeman
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "slices"
+ "time"
+
+ "github.com/onkernel/hypeman-go/internal/apijson"
+ "github.com/onkernel/hypeman-go/internal/requestconfig"
+ "github.com/onkernel/hypeman-go/option"
+ "github.com/onkernel/hypeman-go/packages/param"
+ "github.com/onkernel/hypeman-go/packages/respjson"
+)
+
+// DeviceService contains methods and other services that help with interacting
+// with the hypeman API.
+//
+// Note, unlike clients, this service does not read variables from the environment
+// automatically. You should not instantiate this service directly, and instead use
+// the [NewDeviceService] method instead.
+type DeviceService struct {
+ Options []option.RequestOption
+}
+
+// NewDeviceService generates a new service that applies the given options to each
+// request. These options are applied after the parent client's options (if there
+// is one), and before any request-specific options.
+func NewDeviceService(opts ...option.RequestOption) (r DeviceService) {
+ r = DeviceService{}
+ r.Options = opts
+ return
+}
+
+// Register a device for passthrough
+func (r *DeviceService) New(ctx context.Context, body DeviceNewParams, opts ...option.RequestOption) (res *Device, err error) {
+ opts = slices.Concat(r.Options, opts)
+ path := "devices"
+ err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...)
+ return
+}
+
+// Get device details
+func (r *DeviceService) Get(ctx context.Context, id string, opts ...option.RequestOption) (res *Device, err error) {
+ opts = slices.Concat(r.Options, opts)
+ if id == "" {
+ err = errors.New("missing required id parameter")
+ return
+ }
+ path := fmt.Sprintf("devices/%s", id)
+ err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...)
+ return
+}
+
+// List registered devices
+func (r *DeviceService) List(ctx context.Context, opts ...option.RequestOption) (res *[]Device, err error) {
+ opts = slices.Concat(r.Options, opts)
+ path := "devices"
+ err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...)
+ return
+}
+
+// Unregister device
+func (r *DeviceService) Delete(ctx context.Context, id string, opts ...option.RequestOption) (err error) {
+ opts = slices.Concat(r.Options, opts)
+ opts = append([]option.RequestOption{option.WithHeader("Accept", "*/*")}, opts...)
+ if id == "" {
+ err = errors.New("missing required id parameter")
+ return
+ }
+ path := fmt.Sprintf("devices/%s", id)
+ err = requestconfig.ExecuteNewRequest(ctx, http.MethodDelete, path, nil, nil, opts...)
+ return
+}
+
+// Discover passthrough-capable devices on host
+func (r *DeviceService) ListAvailable(ctx context.Context, opts ...option.RequestOption) (res *[]AvailableDevice, err error) {
+ opts = slices.Concat(r.Options, opts)
+ path := "devices/available"
+ err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...)
+ return
+}
+
+type AvailableDevice struct {
+ // PCI device ID (hex)
+ DeviceID string `json:"device_id,required"`
+ // IOMMU group number
+ IommuGroup int64 `json:"iommu_group,required"`
+ // PCI address
+ PciAddress string `json:"pci_address,required"`
+ // PCI vendor ID (hex)
+ VendorID string `json:"vendor_id,required"`
+ // Currently bound driver (null if none)
+ CurrentDriver string `json:"current_driver,nullable"`
+ // Human-readable device name
+ DeviceName string `json:"device_name"`
+ // Human-readable vendor name
+ VendorName string `json:"vendor_name"`
+ // JSON contains metadata for fields, check presence with [respjson.Field.Valid].
+ JSON struct {
+ DeviceID respjson.Field
+ IommuGroup respjson.Field
+ PciAddress respjson.Field
+ VendorID respjson.Field
+ CurrentDriver respjson.Field
+ DeviceName respjson.Field
+ VendorName respjson.Field
+ ExtraFields map[string]respjson.Field
+ raw string
+ } `json:"-"`
+}
+
+// Returns the unmodified JSON received from the API
+func (r AvailableDevice) RawJSON() string { return r.JSON.raw }
+func (r *AvailableDevice) UnmarshalJSON(data []byte) error {
+ return apijson.UnmarshalRoot(data, r)
+}
+
+type Device struct {
+ // Auto-generated unique identifier (CUID2 format)
+ ID string `json:"id,required"`
+ // Whether the device is currently bound to the vfio-pci driver, which is required
+ // for VM passthrough.
+ //
+ // - true: Device is bound to vfio-pci and ready for (or currently in use by) a VM.
+ // The device's native driver has been unloaded.
+ // - false: Device is using its native driver (e.g., nvidia) or no driver. Hypeman
+ // will automatically bind to vfio-pci when attaching to an instance.
+ BoundToVfio bool `json:"bound_to_vfio,required"`
+ // Registration timestamp (RFC3339)
+ CreatedAt time.Time `json:"created_at,required" format:"date-time"`
+ // PCI device ID (hex)
+ DeviceID string `json:"device_id,required"`
+ // IOMMU group number
+ IommuGroup int64 `json:"iommu_group,required"`
+ // PCI address
+ PciAddress string `json:"pci_address,required"`
+ // Type of PCI device
+ //
+ // Any of "gpu", "pci".
+ Type DeviceType `json:"type,required"`
+ // PCI vendor ID (hex)
+ VendorID string `json:"vendor_id,required"`
+ // Instance ID if attached
+ AttachedTo string `json:"attached_to,nullable"`
+ // Device name (user-provided or auto-generated from PCI address)
+ Name string `json:"name"`
+ // JSON contains metadata for fields, check presence with [respjson.Field.Valid].
+ JSON struct {
+ ID respjson.Field
+ BoundToVfio respjson.Field
+ CreatedAt respjson.Field
+ DeviceID respjson.Field
+ IommuGroup respjson.Field
+ PciAddress respjson.Field
+ Type respjson.Field
+ VendorID respjson.Field
+ AttachedTo respjson.Field
+ Name respjson.Field
+ ExtraFields map[string]respjson.Field
+ raw string
+ } `json:"-"`
+}
+
+// Returns the unmodified JSON received from the API
+func (r Device) RawJSON() string { return r.JSON.raw }
+func (r *Device) UnmarshalJSON(data []byte) error {
+ return apijson.UnmarshalRoot(data, r)
+}
+
+// Type of PCI device
+type DeviceType string
+
+const (
+ DeviceTypeGPU DeviceType = "gpu"
+ DeviceTypePci DeviceType = "pci"
+)
+
+type DeviceNewParams struct {
+ // PCI address of the device (required, e.g., "0000:a2:00.0")
+ PciAddress string `json:"pci_address,required"`
+ // Optional globally unique device name. If not provided, a name is auto-generated
+ // from the PCI address (e.g., "pci-0000-a2-00-0")
+ Name param.Opt[string] `json:"name,omitzero"`
+ paramObj
+}
+
+func (r DeviceNewParams) MarshalJSON() (data []byte, err error) {
+ type shadow DeviceNewParams
+ return param.MarshalObject(r, (*shadow)(&r))
+}
+func (r *DeviceNewParams) UnmarshalJSON(data []byte) error {
+ return apijson.UnmarshalRoot(data, r)
+}
diff --git a/device_test.go b/device_test.go
new file mode 100644
index 0000000..6de5480
--- /dev/null
+++ b/device_test.go
@@ -0,0 +1,132 @@
+// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+package hypeman_test
+
+import (
+ "context"
+ "errors"
+ "os"
+ "testing"
+
+ "github.com/onkernel/hypeman-go"
+ "github.com/onkernel/hypeman-go/internal/testutil"
+ "github.com/onkernel/hypeman-go/option"
+)
+
+func TestDeviceNewWithOptionalParams(t *testing.T) {
+ t.Skip("Prism tests are disabled")
+ baseURL := "http://localhost:4010"
+ if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok {
+ baseURL = envURL
+ }
+ if !testutil.CheckTestServer(t, baseURL) {
+ return
+ }
+ client := hypeman.NewClient(
+ option.WithBaseURL(baseURL),
+ option.WithAPIKey("My API Key"),
+ )
+ _, err := client.Devices.New(context.TODO(), hypeman.DeviceNewParams{
+ PciAddress: "0000:a2:00.0",
+ Name: hypeman.String("l4-gpu"),
+ })
+ if err != nil {
+ var apierr *hypeman.Error
+ if errors.As(err, &apierr) {
+ t.Log(string(apierr.DumpRequest(true)))
+ }
+ t.Fatalf("err should be nil: %s", err.Error())
+ }
+}
+
+func TestDeviceGet(t *testing.T) {
+ t.Skip("Prism tests are disabled")
+ baseURL := "http://localhost:4010"
+ if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok {
+ baseURL = envURL
+ }
+ if !testutil.CheckTestServer(t, baseURL) {
+ return
+ }
+ client := hypeman.NewClient(
+ option.WithBaseURL(baseURL),
+ option.WithAPIKey("My API Key"),
+ )
+ _, err := client.Devices.Get(context.TODO(), "id")
+ if err != nil {
+ var apierr *hypeman.Error
+ if errors.As(err, &apierr) {
+ t.Log(string(apierr.DumpRequest(true)))
+ }
+ t.Fatalf("err should be nil: %s", err.Error())
+ }
+}
+
+func TestDeviceList(t *testing.T) {
+ t.Skip("Prism tests are disabled")
+ baseURL := "http://localhost:4010"
+ if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok {
+ baseURL = envURL
+ }
+ if !testutil.CheckTestServer(t, baseURL) {
+ return
+ }
+ client := hypeman.NewClient(
+ option.WithBaseURL(baseURL),
+ option.WithAPIKey("My API Key"),
+ )
+ _, err := client.Devices.List(context.TODO())
+ if err != nil {
+ var apierr *hypeman.Error
+ if errors.As(err, &apierr) {
+ t.Log(string(apierr.DumpRequest(true)))
+ }
+ t.Fatalf("err should be nil: %s", err.Error())
+ }
+}
+
+func TestDeviceDelete(t *testing.T) {
+ t.Skip("Prism tests are disabled")
+ baseURL := "http://localhost:4010"
+ if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok {
+ baseURL = envURL
+ }
+ if !testutil.CheckTestServer(t, baseURL) {
+ return
+ }
+ client := hypeman.NewClient(
+ option.WithBaseURL(baseURL),
+ option.WithAPIKey("My API Key"),
+ )
+ err := client.Devices.Delete(context.TODO(), "id")
+ if err != nil {
+ var apierr *hypeman.Error
+ if errors.As(err, &apierr) {
+ t.Log(string(apierr.DumpRequest(true)))
+ }
+ t.Fatalf("err should be nil: %s", err.Error())
+ }
+}
+
+func TestDeviceListAvailable(t *testing.T) {
+ t.Skip("Prism tests are disabled")
+ baseURL := "http://localhost:4010"
+ if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok {
+ baseURL = envURL
+ }
+ if !testutil.CheckTestServer(t, baseURL) {
+ return
+ }
+ client := hypeman.NewClient(
+ option.WithBaseURL(baseURL),
+ option.WithAPIKey("My API Key"),
+ )
+ _, err := client.Devices.ListAvailable(context.TODO())
+ if err != nil {
+ var apierr *hypeman.Error
+ if errors.As(err, &apierr) {
+ t.Log(string(apierr.DumpRequest(true)))
+ }
+ t.Fatalf("err should be nil: %s", err.Error())
+ }
+}
diff --git a/instance.go b/instance.go
index 6d37852..d393460 100644
--- a/instance.go
+++ b/instance.go
@@ -144,6 +144,19 @@ func (r *InstanceService) Start(ctx context.Context, id string, opts ...option.R
return
}
+// Returns information about a path in the guest filesystem. Useful for checking if
+// a path exists, its type, and permissions before performing file operations.
+func (r *InstanceService) Stat(ctx context.Context, id string, query InstanceStatParams, opts ...option.RequestOption) (res *PathInfo, err error) {
+ opts = slices.Concat(r.Options, opts)
+ if id == "" {
+ err = errors.New("missing required id parameter")
+ return
+ }
+ path := fmt.Sprintf("instances/%s/stat", id)
+ err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, query, &res, opts...)
+ return
+}
+
// Stop instance (graceful shutdown)
func (r *InstanceService) Stop(ctx context.Context, id string, opts ...option.RequestOption) (res *Instance, err error) {
opts = slices.Concat(r.Options, opts)
@@ -277,6 +290,45 @@ func (r *InstanceNetwork) UnmarshalJSON(data []byte) error {
return apijson.UnmarshalRoot(data, r)
}
+type PathInfo struct {
+ // Whether the path exists
+ Exists bool `json:"exists,required"`
+ // Error message if stat failed (e.g., permission denied). Only set when exists is
+ // false due to an error rather than the path not existing.
+ Error string `json:"error,nullable"`
+ // True if this is a directory
+ IsDir bool `json:"is_dir"`
+ // True if this is a regular file
+ IsFile bool `json:"is_file"`
+ // True if this is a symbolic link (only set when follow_links=false)
+ IsSymlink bool `json:"is_symlink"`
+ // Symlink target path (only set when is_symlink=true)
+ LinkTarget string `json:"link_target,nullable"`
+ // File mode (Unix permissions)
+ Mode int64 `json:"mode"`
+ // File size in bytes
+ Size int64 `json:"size"`
+ // JSON contains metadata for fields, check presence with [respjson.Field.Valid].
+ JSON struct {
+ Exists respjson.Field
+ Error respjson.Field
+ IsDir respjson.Field
+ IsFile respjson.Field
+ IsSymlink respjson.Field
+ LinkTarget respjson.Field
+ Mode respjson.Field
+ Size respjson.Field
+ ExtraFields map[string]respjson.Field
+ raw string
+ } `json:"-"`
+}
+
+// Returns the unmodified JSON received from the API
+func (r PathInfo) RawJSON() string { return r.JSON.raw }
+func (r *PathInfo) UnmarshalJSON(data []byte) error {
+ return apijson.UnmarshalRoot(data, r)
+}
+
type VolumeMount struct {
// Path where volume is mounted in the guest
MountPath string `json:"mount_path,required"`
@@ -354,6 +406,8 @@ type InstanceNewParams struct {
Size param.Opt[string] `json:"size,omitzero"`
// Number of virtual CPUs
Vcpus param.Opt[int64] `json:"vcpus,omitzero"`
+ // Device IDs or names to attach for GPU/PCI passthrough
+ Devices []string `json:"devices,omitzero"`
// Environment variables
Env map[string]string `json:"env,omitzero"`
// Network configuration for the instance
@@ -422,3 +476,19 @@ const (
InstanceLogsParamsSourceVmm InstanceLogsParamsSource = "vmm"
InstanceLogsParamsSourceHypeman InstanceLogsParamsSource = "hypeman"
)
+
+type InstanceStatParams struct {
+ // Path to stat in the guest filesystem
+ Path string `query:"path,required" json:"-"`
+ // Follow symbolic links (like stat vs lstat)
+ FollowLinks param.Opt[bool] `query:"follow_links,omitzero" json:"-"`
+ paramObj
+}
+
+// URLQuery serializes [InstanceStatParams]'s query parameters as `url.Values`.
+func (r InstanceStatParams) URLQuery() (v url.Values, err error) {
+ return apiquery.MarshalWithSettings(r, apiquery.QuerySettings{
+ ArrayFormat: apiquery.ArrayQueryFormatComma,
+ NestedFormat: apiquery.NestedQueryFormatBrackets,
+ })
+}
diff --git a/instance_test.go b/instance_test.go
index 4910848..8b83e37 100644
--- a/instance_test.go
+++ b/instance_test.go
@@ -27,8 +27,9 @@ func TestInstanceNewWithOptionalParams(t *testing.T) {
option.WithAPIKey("My API Key"),
)
_, err := client.Instances.New(context.TODO(), hypeman.InstanceNewParams{
- Image: "docker.io/library/alpine:latest",
- Name: "my-workload-1",
+ Image: "docker.io/library/alpine:latest",
+ Name: "my-workload-1",
+ Devices: []string{"l4-gpu"},
Env: map[string]string{
"PORT": "3000",
"NODE_ENV": "production",
@@ -195,6 +196,36 @@ func TestInstanceStart(t *testing.T) {
}
}
+func TestInstanceStatWithOptionalParams(t *testing.T) {
+ t.Skip("Prism tests are disabled")
+ baseURL := "http://localhost:4010"
+ if envURL, ok := os.LookupEnv("TEST_API_BASE_URL"); ok {
+ baseURL = envURL
+ }
+ if !testutil.CheckTestServer(t, baseURL) {
+ return
+ }
+ client := hypeman.NewClient(
+ option.WithBaseURL(baseURL),
+ option.WithAPIKey("My API Key"),
+ )
+ _, err := client.Instances.Stat(
+ context.TODO(),
+ "id",
+ hypeman.InstanceStatParams{
+ Path: "path",
+ FollowLinks: hypeman.Bool(true),
+ },
+ )
+ if err != nil {
+ var apierr *hypeman.Error
+ if errors.As(err, &apierr) {
+ t.Log(string(apierr.DumpRequest(true)))
+ }
+ t.Fatalf("err should be nil: %s", err.Error())
+ }
+}
+
func TestInstanceStop(t *testing.T) {
t.Skip("Prism tests are disabled")
baseURL := "http://localhost:4010"
diff --git a/internal/apiform/encoder.go b/internal/apiform/encoder.go
index d66b33b..55f53b7 100644
--- a/internal/apiform/encoder.go
+++ b/internal/apiform/encoder.go
@@ -60,6 +60,7 @@ type encoderField struct {
type encoderEntry struct {
reflect.Type
dateFormat string
+ arrayFmt string
root bool
}
@@ -77,6 +78,7 @@ func (e *encoder) typeEncoder(t reflect.Type) encoderFunc {
entry := encoderEntry{
Type: t,
dateFormat: e.dateFormat,
+ arrayFmt: e.arrayFmt,
root: e.root,
}
@@ -178,34 +180,9 @@ func (e *encoder) newPrimitiveTypeEncoder(t reflect.Type) encoderFunc {
}
}
-func arrayKeyEncoder(arrayFmt string) func(string, int) string {
- var keyFn func(string, int) string
- switch arrayFmt {
- case "comma", "repeat":
- keyFn = func(k string, _ int) string { return k }
- case "brackets":
- keyFn = func(key string, _ int) string { return key + "[]" }
- case "indices:dots":
- keyFn = func(k string, i int) string {
- if k == "" {
- return strconv.Itoa(i)
- }
- return k + "." + strconv.Itoa(i)
- }
- case "indices:brackets":
- keyFn = func(k string, i int) string {
- if k == "" {
- return strconv.Itoa(i)
- }
- return k + "[" + strconv.Itoa(i) + "]"
- }
- }
- return keyFn
-}
-
func (e *encoder) newArrayTypeEncoder(t reflect.Type) encoderFunc {
itemEncoder := e.typeEncoder(t.Elem())
- keyFn := arrayKeyEncoder(e.arrayFmt)
+ keyFn := e.arrayKeyEncoder()
return func(key string, v reflect.Value, writer *multipart.Writer) error {
if keyFn == nil {
return fmt.Errorf("apiform: unsupported array format")
@@ -303,13 +280,10 @@ func (e *encoder) newStructTypeEncoder(t reflect.Type) encoderFunc {
})
return func(key string, value reflect.Value, writer *multipart.Writer) error {
- if key != "" {
- key = key + "."
- }
-
+ keyFn := e.objKeyEncoder(key)
for _, ef := range encoderFields {
field := value.FieldByIndex(ef.idx)
- err := ef.fn(key+ef.tag.name, field, writer)
+ err := ef.fn(keyFn(ef.tag.name), field, writer)
if err != nil {
return err
}
@@ -405,6 +379,43 @@ func (e *encoder) newReaderTypeEncoder() encoderFunc {
}
}
+func (e encoder) arrayKeyEncoder() func(string, int) string {
+ var keyFn func(string, int) string
+ switch e.arrayFmt {
+ case "comma", "repeat":
+ keyFn = func(k string, _ int) string { return k }
+ case "brackets":
+ keyFn = func(key string, _ int) string { return key + "[]" }
+ case "indices:dots":
+ keyFn = func(k string, i int) string {
+ if k == "" {
+ return strconv.Itoa(i)
+ }
+ return k + "." + strconv.Itoa(i)
+ }
+ case "indices:brackets":
+ keyFn = func(k string, i int) string {
+ if k == "" {
+ return strconv.Itoa(i)
+ }
+ return k + "[" + strconv.Itoa(i) + "]"
+ }
+ }
+ return keyFn
+}
+
+func (e encoder) objKeyEncoder(parent string) func(string) string {
+ if parent == "" {
+ return func(child string) string { return child }
+ }
+ switch e.arrayFmt {
+ case "brackets":
+ return func(child string) string { return parent + "[" + child + "]" }
+ default:
+ return func(child string) string { return parent + "." + child }
+ }
+}
+
// Given a []byte of json (may either be an empty object or an object that already contains entries)
// encode all of the entries in the map to the json byte array.
func (e *encoder) encodeMapEntries(key string, v reflect.Value, writer *multipart.Writer) error {
@@ -413,10 +424,6 @@ func (e *encoder) encodeMapEntries(key string, v reflect.Value, writer *multipar
value reflect.Value
}
- if key != "" {
- key = key + "."
- }
-
pairs := []mapPair{}
iter := v.MapRange()
@@ -434,8 +441,9 @@ func (e *encoder) encodeMapEntries(key string, v reflect.Value, writer *multipar
})
elementEncoder := e.typeEncoder(v.Type().Elem())
+ keyFn := e.objKeyEncoder(key)
for _, p := range pairs {
- err := elementEncoder(key+string(p.key), p.value, writer)
+ err := elementEncoder(keyFn(p.key), p.value, writer)
if err != nil {
return err
}
diff --git a/internal/apiform/form_test.go b/internal/apiform/form_test.go
index e5eb680..75b3972 100644
--- a/internal/apiform/form_test.go
+++ b/internal/apiform/form_test.go
@@ -123,6 +123,18 @@ type StructUnion struct {
param.APIUnion
}
+type MultipartMarshalerParent struct {
+ Middle MultipartMarshalerMiddleNext `form:"middle"`
+}
+
+type MultipartMarshalerMiddleNext struct {
+ MiddleNext MultipartMarshalerMiddle `form:"middleNext"`
+}
+
+type MultipartMarshalerMiddle struct {
+ Child int `form:"child"`
+}
+
var tests = map[string]struct {
buf string
val any
@@ -366,6 +378,19 @@ true
},
},
},
+ "recursive_struct,brackets": {
+ `--xxx
+Content-Disposition: form-data; name="child[name]"
+
+Alex
+--xxx
+Content-Disposition: form-data; name="name"
+
+Robert
+--xxx--
+`,
+ Recursive{Name: "Robert", Child: &Recursive{Name: "Alex"}},
+ },
"recursive_struct": {
`--xxx
@@ -529,6 +554,30 @@ Content-Disposition: form-data; name="union"
Union: UnionTime(time.Date(2010, 05, 23, 0, 0, 0, 0, time.UTC)),
},
},
+ "deeply-nested-struct,brackets": {
+ `--xxx
+Content-Disposition: form-data; name="middle[middleNext][child]"
+
+10
+--xxx--
+`,
+ MultipartMarshalerParent{
+ Middle: MultipartMarshalerMiddleNext{
+ MiddleNext: MultipartMarshalerMiddle{
+ Child: 10,
+ },
+ },
+ },
+ },
+ "deeply-nested-map,brackets": {
+ `--xxx
+Content-Disposition: form-data; name="middle[middleNext][child]"
+
+10
+--xxx--
+`,
+ map[string]any{"middle": map[string]any{"middleNext": map[string]any{"child": 10}}},
+ },
}
func TestEncode(t *testing.T) {
@@ -553,7 +602,7 @@ func TestEncode(t *testing.T) {
}
raw := buf.Bytes()
if string(raw) != strings.ReplaceAll(test.buf, "\n", "\r\n") {
- t.Errorf("expected %+#v to serialize to '%s' but got '%s'", test.val, test.buf, string(raw))
+ t.Errorf("expected %+#v to serialize to '%s' but got '%s' (with format %s)", test.val, test.buf, string(raw), arrayFmt)
}
})
}
diff --git a/internal/apijson/enum.go b/internal/apijson/enum.go
index 18b218a..5bef11c 100644
--- a/internal/apijson/enum.go
+++ b/internal/apijson/enum.go
@@ -29,7 +29,7 @@ type validatorFunc func(reflect.Value) exactness
var validators sync.Map
var validationRegistry = map[reflect.Type][]validationEntry{}
-func RegisterFieldValidator[T any, V string | bool | int](fieldName string, values ...V) {
+func RegisterFieldValidator[T any, V string | bool | int | float64](fieldName string, values ...V) {
var t T
parentType := reflect.TypeOf(t)
diff --git a/internal/version.go b/internal/version.go
index 79301ae..3c8392e 100644
--- a/internal/version.go
+++ b/internal/version.go
@@ -2,4 +2,4 @@
package internal
-const PackageVersion = "0.7.0" // x-release-please-version
+const PackageVersion = "0.8.0" // x-release-please-version
diff --git a/usage_test.go b/usage_test.go
index cffd18e..6662078 100644
--- a/usage_test.go
+++ b/usage_test.go
@@ -24,6 +24,7 @@ func TestUsage(t *testing.T) {
option.WithBaseURL(baseURL),
option.WithAPIKey("My API Key"),
)
+ t.Skip("Prism tests are disabled")
response, err := client.Health.Check(context.TODO())
if err != nil {
t.Fatalf("err should be nil: %s", err.Error())