client.go 5.97 KB
Newer Older
1 2 3
package http

import (
Jan Winkelmann's avatar
Jan Winkelmann committed
4
	"context"
5
	"fmt"
6
	"io"
7
	"net"
8
	"net/http"
9
	"net/url"
10 11
	"strings"

tavit ohanian's avatar
tavit ohanian committed
12 13
	cmds "gitlab.dms3.io/dms3/public/go-dms3-cmds"
	files "gitlab.dms3.io/dms3/public/go-dms3-files"
14 15
)

16
const (
17 18 19
	ApiUrlFormat = "%s%s/%s?%s"
)

Jeromy's avatar
Jeromy committed
20 21 22 23
var OptionSkipMap = map[string]bool{
	"api": true,
}

24 25
type client struct {
	serverAddress string
rht's avatar
rht committed
26
	httpClient    *http.Client
27 28
	ua            string
	apiPrefix     string
29
	fallback      cmds.Executor
30
}
31

32
// ClientOpt is an option that can be passed to the HTTP client constructor.
33 34
type ClientOpt func(*client)

35
// ClientWithUserAgent specifies the HTTP user agent for the client.
36 37 38 39 40 41
func ClientWithUserAgent(ua string) ClientOpt {
	return func(c *client) {
		c.ua = ua
	}
}

42 43 44 45 46 47 48 49 50
// ClientWithHTTPClient specifies a custom http.Client. Defaults to
// http.DefaultClient.
func ClientWithHTTPClient(hc *http.Client) ClientOpt {
	return func(c *client) {
		c.httpClient = hc
	}
}

// ClientWithAPIPrefix specifies an API URL prefix.
51 52 53 54 55 56
func ClientWithAPIPrefix(apiPrefix string) ClientOpt {
	return func(c *client) {
		c.apiPrefix = apiPrefix
	}
}

57 58 59 60 61 62 63 64 65
// ClientWithFallback adds a fallback executor to the client.
//
// Note: This may run the PreRun function twice.
func ClientWithFallback(exe cmds.Executor) ClientOpt {
	return func(c *client) {
		c.fallback = exe
	}
}

66
// NewClient constructs a new HTTP-backed command executor.
67
func NewClient(address string, opts ...ClientOpt) cmds.Executor {
68 69 70 71 72
	if !strings.HasPrefix(address, "http://") {
		address = "http://" + address
	}

	c := &client{
73
		serverAddress: address,
rht's avatar
rht committed
74
		httpClient:    http.DefaultClient,
tavit ohanian's avatar
tavit ohanian committed
75
		ua:            "go-dms3-cmds/http",
76 77 78 79
	}

	for _, opt := range opts {
		opt(c)
80
	}
81 82

	return c
83
}
84

85
func (c *client) Execute(req *cmds.Request, re cmds.ResponseEmitter, env cmds.Environment) error {
86 87
	cmd := req.Command

88 89 90 91 92
	err := cmd.CheckArguments(req)
	if err != nil {
		return err
	}

93 94 95 96 97 98 99
	if cmd.PreRun != nil {
		err := cmd.PreRun(req, env)
		if err != nil {
			return err
		}
	}

100
	res, err := c.send(req)
Brian Tiger Chow's avatar
Brian Tiger Chow committed
101
	if err != nil {
102 103 104 105 106 107 108 109
		// Unwrap any URL errors. We don't really need to expose the
		// underlying HTTP nonsense to the user.
		if urlerr, ok := err.(*url.Error); ok {
			err = urlerr.Err
		}

		if netoperr, ok := err.(*net.OpError); ok && netoperr.Op == "dial" {
			// Connection refused.
110 111 112 113
			if c.fallback != nil {
				// XXX: this runs the PreRun twice
				return c.fallback.Execute(req, re, env)
			}
tavit ohanian's avatar
tavit ohanian committed
114
			err = fmt.Errorf("cannot connect to the api. Is the daemon running? To run as a standalone CLI command remove the api file in `$DMS3_PATH/api`")
115 116
		}
		return err
Brian Tiger Chow's avatar
Brian Tiger Chow committed
117 118
	}

keks's avatar
keks committed
119 120 121 122
	if cmd.PostRun != nil {
		if typer, ok := re.(interface {
			Type() cmds.PostRunType
		}); ok && cmd.PostRun[typer.Type()] != nil {
keks's avatar
keks committed
123
			err := cmd.PostRun[typer.Type()](res, re)
keks's avatar
keks committed
124 125 126 127 128 129
			closeErr := re.CloseWithError(err)
			if closeErr == cmds.ErrClosingClosedEmitter {
				// ignore double close errors
				return nil
			}

Steven Allen's avatar
Steven Allen committed
130
			return closeErr
keks's avatar
keks committed
131 132 133
		}
	}

134 135 136
	return cmds.Copy(re, res)
}

137
func (c *client) toHTTPRequest(req *cmds.Request) (*http.Request, error) {
138
	query, err := getQuery(req)
139 140 141 142
	if err != nil {
		return nil, err
	}

143
	var fileReader *files.MultiFileReader
Łukasz Magiera's avatar
Łukasz Magiera committed
144 145 146
	var reader io.Reader // in case we have no body to send we need to provide
	// untyped nil to http.NewRequest

147 148 149
	if bodyArgs := req.BodyArgs(); bodyArgs != nil {
		// In the end, this wraps a file reader in a file reader.
		// However, such is life.
Łukasz Magiera's avatar
Łukasz Magiera committed
150 151
		fileReader = files.NewMultiFileReader(files.NewMapDirectory(map[string]files.Node{
			"stdin": files.NewReaderFile(bodyArgs),
152 153 154
		}), true)
		reader = fileReader
	} else if req.Files != nil {
155
		fileReader = files.NewMultiFileReader(req.Files, true)
156
		reader = fileReader
157 158
	}

159 160
	path := strings.Join(req.Path, "/")
	url := fmt.Sprintf(ApiUrlFormat, c.serverAddress, c.apiPrefix, path, query)
161

162
	httpReq, err := http.NewRequest("POST", url, reader)
163 164 165 166 167 168
	if err != nil {
		return nil, err
	}

	// TODO extract string consts?
	if fileReader != nil {
169
		httpReq.Header.Set(contentTypeHeader, "multipart/form-data; boundary="+fileReader.Boundary())
170
	} else {
171
		httpReq.Header.Set(contentTypeHeader, applicationOctetStream)
172
	}
173 174
	httpReq.Header.Set(uaHeader, c.ua)

175
	httpReq = httpReq.WithContext(req.Context)
rht's avatar
rht committed
176
	httpReq.Close = true
177

178 179 180
	return httpReq, nil
}

181
func (c *client) send(req *cmds.Request) (cmds.Response, error) {
182
	if req.Context == nil {
183
		log.Warnf("no context set in request")
184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202
		req.Context = context.Background()
	}

	// save user-provided encoding
	previousUserProvidedEncoding, found := req.Options[cmds.EncLong].(string)

	// override with json to send to server
	req.SetOption(cmds.EncLong, cmds.JSON)

	// stream channel output
	req.SetOption(cmds.ChanOpt, true)

	// build http request
	httpReq, err := c.toHTTPRequest(req)
	if err != nil {
		return nil, err
	}

	// send http request
rht's avatar
rht committed
203 204 205 206
	httpRes, err := c.httpClient.Do(httpReq)
	if err != nil {
		return nil, err
	}
207

208
	// parse using the overridden JSON encoding in request
209
	res, err := parseResponse(httpRes, req)
rht's avatar
rht committed
210
	if err != nil {
rht's avatar
rht committed
211
		return nil, err
212
	}
rht's avatar
rht committed
213

214
	// reset request encoding to what it was before
rht's avatar
rht committed
215 216 217 218
	if found && len(previousUserProvidedEncoding) > 0 {
		// reset to user provided encoding after sending request
		// NB: if user has provided an encoding but it is the empty string,
		// still leave it as JSON.
219
		req.SetOption(cmds.EncLong, previousUserProvidedEncoding)
rht's avatar
rht committed
220 221 222
	}

	return res, nil
223 224
}

225
func getQuery(req *cmds.Request) (string, error) {
226
	query := url.Values{}
227 228

	for k, v := range req.Options {
Jeromy's avatar
Jeromy committed
229
		if OptionSkipMap[k] {
Jeromy's avatar
Jeromy committed
230 231
			continue
		}
232

233 234 235
		switch val := v.(type) {
		case []string:
			for _, o := range val {
236 237
				query.Add(k, o)
			}
238 239 240 241 242
		case bool, int, int64, uint, uint64, float64, string:
			str := fmt.Sprintf("%v", v)
			query.Set(k, str)
		default:
			return "", fmt.Errorf("unsupported query parameter type. key: %s, value: %v", k, v)
243
		}
244
	}
245

246 247
	args := req.Arguments
	argDefs := req.Command.Arguments
248

249 250 251 252 253
	argDefIndex := 0

	for _, arg := range args {
		argDef := argDefs[argDefIndex]
		// skip ArgFiles
Steven Allen's avatar
Steven Allen committed
254
		for argDef.Type == cmds.ArgFile {
255 256
			argDefIndex++
			argDef = argDefs[argDefIndex]
257 258
		}

259 260 261 262
		query.Add("arg", arg)

		if len(argDefs) > argDefIndex+1 {
			argDefIndex++
263
		}
264
	}
265

266
	return query.Encode(), nil
267
}