Skip to content

Commit 3540d00

Browse files
wilkeclaude
andcommitted
Add clients/shock-go zero-dependency Go client library and update CLI
Create a new Go client library at clients/shock-go/ that replaces the vendored go-shock-client with a modern, stdlib-only implementation. Key improvements over the old client: - Fix Linkages JSON tag ("linkages" -> "linkage" matching server) - Include VersionParts in JSON (was excluded with json:"-") - Add missing Node fields: Locations, Restore - Type ACL responses as *DisplayAcl instead of interface{} - Type UnpackArchiveNode response as []Node instead of interface{} - Add context.Context support on all methods - Support custom *http.Client via WithHTTPClient option - Remove log.Fatal calls (return errors instead) - Remove deprecated ioutil usage throughout - Stream multipart uploads via io.Pipe (no full-file buffering) - Add new location management endpoints - Add functional options pattern for node creation/updates Update shock-client/ CLI to use the new library with identical behavior. The server continues using the vendored old client (out of scope). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d82c1e1 commit 3540d00

37 files changed

Lines changed: 3382 additions & 89 deletions

‎clients/shock-go/acl.go‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package shock
2+
3+
import (
4+
"context"
5+
"net/url"
6+
)
7+
8+
// GetAcl retrieves the ACL for a node.
9+
func (c *Client) GetAcl(ctx context.Context, nodeID string) (*DisplayAcl, error) {
10+
var acl DisplayAcl
11+
if err := c.doRequest(ctx, "GET", "/node/"+nodeID+"/acl", nil, &acl); err != nil {
12+
return nil, err
13+
}
14+
return &acl, nil
15+
}
16+
17+
// PutAcl adds users to an ACL type on a node.
18+
func (c *Client) PutAcl(ctx context.Context, nodeID, aclType, users string) (*DisplayAcl, error) {
19+
query := url.Values{}
20+
if users != "" {
21+
query.Set("users", users)
22+
}
23+
var acl DisplayAcl
24+
if err := c.doRequest(ctx, "PUT", "/node/"+nodeID+"/acl/"+aclType, query, &acl); err != nil {
25+
return nil, err
26+
}
27+
return &acl, nil
28+
}
29+
30+
// DeleteAcl removes users from an ACL type on a node.
31+
func (c *Client) DeleteAcl(ctx context.Context, nodeID, aclType, users string) (*DisplayAcl, error) {
32+
query := url.Values{}
33+
if users != "" {
34+
query.Set("users", users)
35+
}
36+
var acl DisplayAcl
37+
if err := c.doRequest(ctx, "DELETE", "/node/"+nodeID+"/acl/"+aclType, query, &acl); err != nil {
38+
return nil, err
39+
}
40+
return &acl, nil
41+
}
42+
43+
// MakePublic sets a node's read ACL to public.
44+
func (c *Client) MakePublic(ctx context.Context, nodeID string) (*DisplayAcl, error) {
45+
var acl DisplayAcl
46+
if err := c.doRequest(ctx, "PUT", "/node/"+nodeID+"/acl/public_read", nil, &acl); err != nil {
47+
return nil, err
48+
}
49+
return &acl, nil
50+
}
51+
52+
// ChownNode changes the owner of a node.
53+
func (c *Client) ChownNode(ctx context.Context, nodeID, user string) (*DisplayAcl, error) {
54+
query := url.Values{}
55+
query.Set("users", user)
56+
var acl DisplayAcl
57+
if err := c.doRequest(ctx, "PUT", "/node/"+nodeID+"/acl/owner", query, &acl); err != nil {
58+
return nil, err
59+
}
60+
return &acl, nil
61+
}

‎clients/shock-go/client.go‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package shock
2+
3+
import (
4+
"net/http"
5+
"time"
6+
)
7+
8+
// Client is a Shock API client.
9+
type Client struct {
10+
baseURL string
11+
token string
12+
authType string
13+
httpClient *http.Client
14+
debug bool
15+
}
16+
17+
// Option configures a Client.
18+
type Option func(*Client)
19+
20+
// New creates a new Shock client for the given base URL.
21+
func New(baseURL string, opts ...Option) *Client {
22+
c := &Client{
23+
baseURL: baseURL,
24+
authType: "OAuth",
25+
httpClient: &http.Client{
26+
Timeout: 60 * time.Second,
27+
},
28+
}
29+
for _, opt := range opts {
30+
opt(c)
31+
}
32+
return c
33+
}
34+
35+
// WithToken sets the authentication token.
36+
func WithToken(token string) Option {
37+
return func(c *Client) {
38+
c.token = token
39+
}
40+
}
41+
42+
// WithAuthType sets the authorization type (e.g. "mgrast", "OAuth").
43+
func WithAuthType(authType string) Option {
44+
return func(c *Client) {
45+
c.authType = authType
46+
}
47+
}
48+
49+
// WithHTTPClient sets a custom HTTP client.
50+
func WithHTTPClient(hc *http.Client) Option {
51+
return func(c *Client) {
52+
c.httpClient = hc
53+
}
54+
}
55+
56+
// WithDebug enables debug logging to stderr.
57+
func WithDebug(debug bool) Option {
58+
return func(c *Client) {
59+
c.debug = debug
60+
}
61+
}
62+
63+
// SetToken updates the authentication token.
64+
func (c *Client) SetToken(token string) {
65+
c.token = token
66+
}

‎clients/shock-go/download.go‎

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
package shock
2+
3+
import (
4+
"compress/gzip"
5+
"context"
6+
"crypto/md5"
7+
"encoding/json"
8+
"fmt"
9+
"io"
10+
"net/http"
11+
"net/url"
12+
"os"
13+
"strconv"
14+
)
15+
16+
type downloadConfig struct {
17+
uncompress string
18+
computeMD5 bool
19+
}
20+
21+
// DownloadOption configures a DownloadToFile call.
22+
type DownloadOption func(*downloadConfig)
23+
24+
// WithUncompress sets the decompression method for downloads (e.g. "gzip").
25+
func WithUncompress(method string) DownloadOption {
26+
return func(cfg *downloadConfig) {
27+
cfg.uncompress = method
28+
}
29+
}
30+
31+
// WithComputeMD5 enables MD5 checksum computation during download.
32+
func WithComputeMD5() DownloadOption {
33+
return func(cfg *downloadConfig) {
34+
cfg.computeMD5 = true
35+
}
36+
}
37+
38+
// Download streams a node's file data. The caller must close the returned ReadCloser.
39+
func (c *Client) Download(ctx context.Context, nodeID string) (io.ReadCloser, error) {
40+
query := url.Values{"download": {""}}
41+
u := c.buildURL("/node/"+nodeID, query)
42+
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
43+
if err != nil {
44+
return nil, err
45+
}
46+
c.setAuth(req)
47+
c.debugf("GET %s (download)", u)
48+
49+
res, err := c.httpClient.Do(req)
50+
if err != nil {
51+
return nil, err
52+
}
53+
54+
if res.StatusCode == http.StatusOK {
55+
return res.Body, nil
56+
}
57+
58+
// Error response — parse JSON envelope
59+
defer res.Body.Close()
60+
body, _ := io.ReadAll(res.Body)
61+
var envelope struct {
62+
Error []string `json:"error"`
63+
}
64+
if json.Unmarshal(body, &envelope) == nil && len(envelope.Error) > 0 {
65+
return nil, &APIError{StatusCode: res.StatusCode, Errors: envelope.Error}
66+
}
67+
return nil, &APIError{StatusCode: res.StatusCode, Errors: []string{string(body)}}
68+
}
69+
70+
// DownloadRange streams a byte range from a node's file.
71+
func (c *Client) DownloadRange(ctx context.Context, nodeID string, seek, length int64) (io.ReadCloser, error) {
72+
query := url.Values{
73+
"download": {""},
74+
"seek": {strconv.FormatInt(seek, 10)},
75+
"length": {strconv.FormatInt(length, 10)},
76+
}
77+
u := c.buildURL("/node/"+nodeID, query)
78+
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
79+
if err != nil {
80+
return nil, err
81+
}
82+
c.setAuth(req)
83+
84+
res, err := c.httpClient.Do(req)
85+
if err != nil {
86+
return nil, err
87+
}
88+
89+
if res.StatusCode == http.StatusOK {
90+
return res.Body, nil
91+
}
92+
93+
defer res.Body.Close()
94+
body, _ := io.ReadAll(res.Body)
95+
var envelope struct {
96+
Error []string `json:"error"`
97+
}
98+
if json.Unmarshal(body, &envelope) == nil && len(envelope.Error) > 0 {
99+
return nil, &APIError{StatusCode: res.StatusCode, Errors: envelope.Error}
100+
}
101+
return nil, &APIError{StatusCode: res.StatusCode, Errors: []string{string(body)}}
102+
}
103+
104+
// DownloadToFile downloads a node's file to a local path, optionally decompressing and computing MD5.
105+
func (c *Client) DownloadToFile(ctx context.Context, nodeID, filepath string, opts ...DownloadOption) (int64, string, error) {
106+
cfg := &downloadConfig{}
107+
for _, opt := range opts {
108+
opt(cfg)
109+
}
110+
111+
body, err := c.Download(ctx, nodeID)
112+
if err != nil {
113+
return 0, "", err
114+
}
115+
defer body.Close()
116+
117+
localFile, err := os.Create(filepath)
118+
if err != nil {
119+
return 0, "", err
120+
}
121+
defer localFile.Close()
122+
123+
md5h := md5.New()
124+
var size int64
125+
126+
if cfg.uncompress == "gzip" {
127+
var src io.Reader = body
128+
if cfg.computeMD5 {
129+
// MD5 on compressed stream, then decompress
130+
pReader, pWriter := io.Pipe()
131+
defer pReader.Close()
132+
dst := io.MultiWriter(pWriter, md5h)
133+
go func() {
134+
io.Copy(dst, body)
135+
pWriter.Close()
136+
}()
137+
src = pReader
138+
}
139+
gr, gerr := gzip.NewReader(src)
140+
if gerr != nil {
141+
return 0, "", gerr
142+
}
143+
defer gr.Close()
144+
size, err = io.Copy(localFile, gr)
145+
if err != nil {
146+
return 0, "", err
147+
}
148+
} else {
149+
var dst io.Writer
150+
if cfg.computeMD5 {
151+
dst = io.MultiWriter(localFile, md5h)
152+
} else {
153+
dst = localFile
154+
}
155+
size, err = io.Copy(dst, body)
156+
if err != nil {
157+
return 0, "", err
158+
}
159+
}
160+
161+
var md5sum string
162+
if cfg.computeMD5 {
163+
md5sum = fmt.Sprintf("%x", md5h.Sum(nil))
164+
}
165+
return size, md5sum, nil
166+
}
167+
168+
// GetDownloadURL gets a pre-authenticated download URL for a node.
169+
func (c *Client) GetDownloadURL(ctx context.Context, nodeID string) (*PreAuthResponse, error) {
170+
query := url.Values{"download_url": {""}}
171+
var resp PreAuthResponse
172+
if err := c.doRequest(ctx, "GET", "/node/"+nodeID, query, &resp); err != nil {
173+
return nil, err
174+
}
175+
return &resp, nil
176+
}
177+
178+
// DownloadURL returns the direct download URL for a node (no pre-auth).
179+
func (c *Client) DownloadURL(nodeID string) string {
180+
query := url.Values{"download": {""}}
181+
return c.buildURL("/node/"+nodeID, query)
182+
}

‎clients/shock-go/errors.go‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package shock
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
)
7+
8+
// APIError represents an error returned by the Shock API.
9+
type APIError struct {
10+
StatusCode int
11+
Errors []string
12+
}
13+
14+
func (e *APIError) Error() string {
15+
if len(e.Errors) > 0 {
16+
return fmt.Sprintf("shock API error (status %d): %s", e.StatusCode, strings.Join(e.Errors, "; "))
17+
}
18+
return fmt.Sprintf("shock API error (status %d)", e.StatusCode)
19+
}

‎clients/shock-go/go.mod‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module github.com/MG-RAST/Shock/clients/shock-go
2+
3+
go 1.22

0 commit comments

Comments
 (0)