Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: pull client functionality and CLI command #148

Merged
merged 14 commits into from
Nov 15, 2023
16 changes: 15 additions & 1 deletion client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ type RequestOptions struct {

type RequestResponse struct {
StatusCode int
Headers map[string]string
anpep marked this conversation as resolved.
Show resolved Hide resolved
// ChangeID is typically set when an AsyncRequest type is performed. The
// change id allows for introspection and progress tracking of the request.
ChangeID string
Expand Down Expand Up @@ -321,7 +322,11 @@ func (rq *defaultRequester) Do(ctx context.Context, opts *RequestOptions) (*Requ

// Is the result expecting a caller-managed raw body?
if opts.Type == RawRequest {
return &RequestResponse{Body: httpResp.Body}, nil
return &RequestResponse{
StatusCode: httpResp.StatusCode,
anpep marked this conversation as resolved.
Show resolved Hide resolved
Headers: headerToMap(httpResp.Header),
Body: httpResp.Body,
}, nil
}

defer httpResp.Body.Close()
Expand Down Expand Up @@ -375,11 +380,20 @@ func (rq *defaultRequester) Do(ctx context.Context, opts *RequestOptions) (*Requ
// Common response
return &RequestResponse{
StatusCode: serverResp.StatusCode,
Headers: headerToMap(httpResp.Header),
ChangeID: serverResp.Change,
Result: serverResp.Result,
}, nil
}

func headerToMap(h http.Header) map[string]string {
anpep marked this conversation as resolved.
Show resolved Hide resolved
m := make(map[string]string)
for k, v := range h {
m[k] = v[len(v)-1]
}
return m
}

func decodeInto(reader io.Reader, v interface{}) error {
dec := json.NewDecoder(reader)
if err := dec.Decode(v); err != nil {
Expand Down
97 changes: 97 additions & 0 deletions client/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@ package client

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime"
"mime/multipart"
"net/url"
"os"
"strconv"
Expand Down Expand Up @@ -364,3 +368,96 @@ func (client *Client) RemovePath(opts *RemovePathOptions) error {

return nil
}

// PullOptions contains the options for a call to Pull.
type PullOptions struct {
// Path indicates the absolute path of the file in the remote system
// (required).
Path string

// Target is the destination io.Writer that will receive the data (required).
// During a call to Pull, Target may be written to even if an error is returned.
Target io.Writer
}

// Pull retrieves a file from the remote system.
func (client *Client) Pull(opts *PullOptions) error {
resp, err := client.Requester().Do(context.Background(), &RequestOptions{
Type: RawRequest,
Method: "GET",
Path: "/v1/files",
Query: map[string][]string{
"action": {"read"},
"path": {opts.Path},
},
Headers: map[string]string{
"Accept": "multipart/form-data",
},
Body: nil,
anpep marked this conversation as resolved.
Show resolved Hide resolved
})
if err != nil {
return err
}
defer resp.Body.Close()

// Obtain Content-Type to check for a multipart payload and parse its value
// in order to obtain the multipart boundary.
mediaType, params, err := mime.ParseMediaType(resp.Headers["Content-Type"])
anpep marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return fmt.Errorf("invalid Content-Type: %w", err)
anpep marked this conversation as resolved.
Show resolved Hide resolved
}
if mediaType != "multipart/form-data" {
// Not an error response after all.
return fmt.Errorf("expected a multipart response, got %q", mediaType)
}

mr := multipart.NewReader(resp.Body, params["boundary"])
filesPart, err := mr.NextPart()
if err != nil {
return fmt.Errorf("cannot decode multipart payload: %w", err)
}
defer filesPart.Close()

if filesPart.FormName() != "files" {
return fmt.Errorf(`expected first field name to be "files", got %q`, filesPart.FormName())
}
if _, err := io.Copy(opts.Target, filesPart); err != nil {
return fmt.Errorf("cannot write: %w", err)
anpep marked this conversation as resolved.
Show resolved Hide resolved
}

responsePart, err := mr.NextPart()
if err != nil {
return fmt.Errorf("cannot decode multipart payload: %w", err)
}
defer responsePart.Close()
if responsePart.FormName() != "response" {
return fmt.Errorf(`expected second field name to be "response", got %q`, responsePart.FormName())
}

// Process response metadata (see defaultRequester.Do() in client package)
anpep marked this conversation as resolved.
Show resolved Hide resolved
var multipartResp response
if err := decodeInto(responsePart, &multipartResp); err != nil {
return err
}
if err := multipartResp.err(); err != nil {
return err
}
if multipartResp.Type != "sync" {
return fmt.Errorf("expected sync response, got %q", multipartResp.Type)
}

requestResponse := &RequestResponse{Result: multipartResp.Result}

// Decode response result.
var fr []fileResult
if err := requestResponse.DecodeResult(&fr); err != nil {
return fmt.Errorf("cannot unmarshal result: %w", err)
}
if len(fr) != 1 {
return fmt.Errorf("expected exactly one result from API, got %d", len(fr))
}
if fr[0].Error != nil {
return fr[0].Error
}
return nil
}
Loading