Skip to content

Commit 8f8887d

Browse files
committed
Initial commit to v1
1 parent cdaa9cc commit 8f8887d

File tree

11 files changed

+699
-0
lines changed

11 files changed

+699
-0
lines changed

Makefile

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Paths to tools needed in dependencies
2+
GO := $(shell which go)
3+
NPM := $(shell which npm)
4+
5+
# Build flags
6+
BUILD_MODULE := $(shell go list -m)
7+
BUILD_LD_FLAGS += -X $(BUILD_MODULE)/pkg/version.GitSource=${BUILD_MODULE}
8+
BUILD_LD_FLAGS += -X $(BUILD_MODULE)/pkg/version.GitTag=$(shell git describe --tags)
9+
BUILD_LD_FLAGS += -X $(BUILD_MODULE)/pkg/version.GitBranch=$(shell git name-rev HEAD --name-only --always)
10+
BUILD_LD_FLAGS += -X $(BUILD_MODULE)/pkg/version.GitHash=$(shell git rev-parse HEAD)
11+
BUILD_LD_FLAGS += -X $(BUILD_MODULE)/pkg/version.GoBuildTime=$(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
12+
BUILD_FLAGS = -ldflags "-s -w $(BUILD_LD_FLAGS)"
13+
14+
# Paths to locations, etc
15+
BUILD_DIR := "build"
16+
PLUGIN_DIR := $(filter-out $(wildcard plugin/*.go), $(wildcard plugin/*))
17+
NPM_DIR := $(wildcard npm/*)
18+
CMD_DIR := $(wildcard cmd/*)
19+
20+
# Targets
21+
all: clean cmd npm plugins
22+
23+
cmd: $(CMD_DIR)
24+
25+
plugins: $(PLUGIN_DIR)
26+
27+
npm: $(NPM_DIR)
28+
29+
test:
30+
@${GO} mod tidy
31+
@${GO} test -v ./pkg/...
32+
33+
$(CMD_DIR): dependencies mkdir
34+
@echo Build cmd $(notdir $@)
35+
@${GO} build ${BUILD_FLAGS} -o ${BUILD_DIR}/$(notdir $@) ./$@
36+
37+
$(PLUGIN_DIR): dependencies mkdir
38+
@echo Build plugin $(notdir $@)
39+
@${GO} build -buildmode=plugin ${BUILD_FLAGS} -o ${BUILD_DIR}/$(notdir $@).plugin ./$@
40+
41+
$(NPM_DIR): dependencies-npm
42+
@echo Build npm $(notdir $@)
43+
@cd $@ && npm install && npm run build
44+
45+
FORCE:
46+
47+
dependencies:
48+
@test -f "${GO}" && test -x "${GO}" || (echo "Missing go binary" && exit 1)
49+
50+
dependencies-npm:
51+
@test -f "${NPM}" && test -x "${NPM}" || (echo "Missing npm binary" && exit 1)
52+
53+
mkdir:
54+
@echo Mkdir ${BUILD_DIR}
55+
@install -d ${BUILD_DIR}
56+
57+
clean:
58+
@echo Clean
59+
@rm -fr $(BUILD_DIR)
60+
@find ${NPM_DIR} -name dist -type d -prune -exec rm -fr {} \;
61+
@${GO} mod tidy
62+
@${GO} clean
63+
64+
distclean: clean
65+
@find ${NPM_DIR} -name node_modules -type d -prune -exec rm -fr {} \;

go.mod

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
module github.com/mutablelogic/go-client
2+
3+
go 1.20
4+
5+
require (
6+
github.com/djthorpe/go-errors v1.0.3
7+
github.com/mutablelogic/go-server v1.1.16
8+
github.com/stretchr/testify v1.8.4
9+
)
10+
11+
require (
12+
github.com/davecgh/go-spew v1.1.1 // indirect
13+
github.com/pmezard/go-difflib v1.0.0 // indirect
14+
gopkg.in/yaml.v3 v3.0.1 // indirect
15+
)

go.sum

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3+
github.com/djthorpe/go-errors v1.0.3 h1:GZeMPkC1mx2vteXLI/gvxZS0Ee9zxzwD1mcYyKU5jD0=
4+
github.com/djthorpe/go-errors v1.0.3/go.mod h1:HtfrZnMd6HsX75Mtbv9Qcnn0BqOrrFArvCaj3RMnZhY=
5+
github.com/mutablelogic/go-server v1.1.16 h1:in897acxmw/WIdxoafDQFcJ5mpRaLriCK5bXUy3JYt0=
6+
github.com/mutablelogic/go-server v1.1.16/go.mod h1:R9jcFJzVKtduwmWazGaBX5EBRqlQ0BCpmAxUrlWRZLs=
7+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
8+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
9+
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
10+
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
11+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
12+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
13+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
14+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

pkg/client/client.go

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
package client
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"encoding/xml"
7+
"fmt"
8+
"io"
9+
"mime"
10+
"net/http"
11+
"net/url"
12+
"os"
13+
"sync"
14+
"time"
15+
16+
// Namespace imports
17+
. "github.com/djthorpe/go-errors"
18+
)
19+
20+
///////////////////////////////////////////////////////////////////////////////
21+
// INTERFACES
22+
23+
// Unmarshaler is an interface which can be implemented by a type to
24+
// unmarshal a response body
25+
type Unmarshaler interface {
26+
Unmarshal(mimetype string, r io.Reader) error
27+
}
28+
29+
///////////////////////////////////////////////////////////////////////////////
30+
// TYPES
31+
32+
type Client struct {
33+
sync.Mutex
34+
*http.Client
35+
36+
endpoint *url.URL
37+
ua string
38+
rate float32 // number of requests allowed per second
39+
strict bool
40+
token Token // token for authentication on requests
41+
ts time.Time
42+
skipverify bool
43+
}
44+
45+
type ClientOpt func(*Client) error
46+
type RequestOpt func(*http.Request) error
47+
48+
///////////////////////////////////////////////////////////////////////////////
49+
// GLOBALS
50+
51+
const (
52+
DefaultTimeout = time.Second * 10
53+
DefaultUserAgent = "github.com/mutablelogic/go-server"
54+
PathSeparator = string(os.PathSeparator)
55+
ContentTypeJson = "application/json"
56+
ContentTypeTextXml = "text/xml"
57+
ContentTypeApplicationXml = "application/xml"
58+
ContentTypeTextPlain = "text/plain"
59+
ContentTypeTextHTML = "text/html"
60+
ContentTypeBinary = "application/octet-stream"
61+
)
62+
63+
///////////////////////////////////////////////////////////////////////////////
64+
// LIFECYCLE
65+
66+
// New creates a new client with options. OptEndpoint is required as an option
67+
// to set the endpoint for all requests.
68+
func New(opts ...ClientOpt) (*Client, error) {
69+
this := new(Client)
70+
71+
// Create a HTTP client
72+
this.Client = &http.Client{
73+
Timeout: DefaultTimeout,
74+
Transport: http.DefaultTransport,
75+
}
76+
77+
// Apply options
78+
for _, opt := range opts {
79+
if err := opt(this); err != nil {
80+
return nil, err
81+
}
82+
}
83+
84+
// If no endpoint, then return error
85+
if this.endpoint == nil {
86+
return nil, ErrBadParameter.With("missing endppint")
87+
}
88+
89+
// Return success
90+
return this, nil
91+
}
92+
93+
///////////////////////////////////////////////////////////////////////////////
94+
// STRINGIFY
95+
96+
func (client *Client) String() string {
97+
str := "<client"
98+
if client.endpoint != nil {
99+
str += fmt.Sprintf(" endpoint=%q", redactedUrl(client.endpoint))
100+
}
101+
if client.Client.Timeout > 0 {
102+
str += fmt.Sprint(" timeout=", client.Client.Timeout)
103+
}
104+
return str + ">"
105+
}
106+
107+
///////////////////////////////////////////////////////////////////////////////
108+
// PUBLIC METHODS
109+
110+
// Do a JSON request with a payload, populate an object with the response
111+
// and return any errors
112+
func (client *Client) Do(in Payload, out any, opts ...RequestOpt) error {
113+
client.Mutex.Lock()
114+
defer client.Mutex.Unlock()
115+
116+
// Check rate limit - sleep until next request can be made
117+
now := time.Now()
118+
if !client.ts.IsZero() && client.rate > 0.0 {
119+
next := client.ts.Add(time.Duration(float32(time.Second) / client.rate))
120+
if next.After(now) {
121+
time.Sleep(next.Sub(now))
122+
}
123+
}
124+
125+
// Set timestamp at return
126+
defer func(now time.Time) {
127+
client.ts = now
128+
}(now)
129+
130+
// Make a request
131+
var body io.Reader
132+
var method string = http.MethodGet
133+
var accept, mimetype string
134+
if in != nil {
135+
if in.Type() != "" {
136+
data, err := json.Marshal(in)
137+
if err != nil {
138+
return err
139+
}
140+
body = bytes.NewReader(data)
141+
}
142+
method = in.Method()
143+
accept = in.Accept()
144+
mimetype = in.Type()
145+
}
146+
req, err := client.request(method, accept, mimetype, body)
147+
if err != nil {
148+
return err
149+
}
150+
151+
// If debug, then log the payload
152+
if debug, ok := client.Client.Transport.(*logtransport); ok {
153+
if body != nil {
154+
debug.Payload(in)
155+
}
156+
}
157+
158+
// If client token is set, then add to request
159+
if client.token.Scheme != "" {
160+
opts = append([]RequestOpt{OptToken(client.token)}, opts...)
161+
}
162+
163+
return do(client.Client, req, accept, client.strict, out, opts...)
164+
}
165+
166+
///////////////////////////////////////////////////////////////////////////////
167+
// PRIVATE METHODS
168+
169+
// request creates a request which can be used to return responses. The accept
170+
// parameter is the accepted mime-type of the response. If the accept parameter is empty,
171+
// then the default is application/json.
172+
func (client *Client) request(method, accept, mimetype string, body io.Reader) (*http.Request, error) {
173+
// Return error if no endpoint is set
174+
if client.endpoint == nil {
175+
return nil, ErrBadParameter.With("missing endpoint")
176+
}
177+
178+
// Make a request
179+
r, err := http.NewRequest(method, client.endpoint.String(), body)
180+
if err != nil {
181+
return nil, err
182+
}
183+
184+
// Set the credentials and user agent
185+
if body != nil {
186+
if mimetype == "" {
187+
mimetype = ContentTypeJson
188+
}
189+
r.Header.Set("Content-Type", mimetype)
190+
}
191+
if accept != "" {
192+
r.Header.Set("Accept", accept)
193+
}
194+
if client.ua != "" {
195+
r.Header.Set("User-Agent", client.ua)
196+
}
197+
198+
// Return success
199+
return r, nil
200+
}
201+
202+
// Do will make a JSON request, populate an object with the response and return any errors
203+
func do(client *http.Client, req *http.Request, accept string, strict bool, out any, opts ...RequestOpt) error {
204+
// Apply request options
205+
for _, opt := range opts {
206+
if err := opt(req); err != nil {
207+
return err
208+
}
209+
}
210+
211+
// Do the request
212+
response, err := client.Do(req)
213+
if err != nil {
214+
return err
215+
}
216+
defer response.Body.Close()
217+
218+
// Get content type
219+
mimetype, err := respContentType(response)
220+
if err != nil {
221+
return ErrUnexpectedResponse.With(mimetype)
222+
}
223+
224+
// Check status code
225+
if response.StatusCode < 200 || response.StatusCode > 299 {
226+
// Read any information from the body
227+
data, err := io.ReadAll(response.Body)
228+
if err != nil {
229+
return err
230+
}
231+
return ErrUnexpectedResponse.With(response.Status, ": ", string(data))
232+
}
233+
234+
// When in strict mode, check content type returned is as expected
235+
if strict && accept != "" {
236+
if mimetype != accept {
237+
return ErrUnexpectedResponse.Withf("strict mode: unexpected responsse with %q", mimetype)
238+
}
239+
}
240+
241+
// Return success if out is nil
242+
if out == nil {
243+
return nil
244+
}
245+
246+
// Decode the body
247+
switch mimetype {
248+
case ContentTypeJson:
249+
if err := json.NewDecoder(response.Body).Decode(out); err != nil {
250+
return err
251+
}
252+
case ContentTypeTextXml, ContentTypeApplicationXml:
253+
if err := xml.NewDecoder(response.Body).Decode(out); err != nil {
254+
return err
255+
}
256+
default:
257+
if v, ok := out.(Unmarshaler); ok {
258+
return v.Unmarshal(mimetype, response.Body)
259+
} else {
260+
return ErrInternalAppError.Withf("do: response does not implement Unmarshaler for %q", mimetype)
261+
}
262+
}
263+
264+
// Return success
265+
return nil
266+
}
267+
268+
// Parse the response content type
269+
func respContentType(resp *http.Response) (string, error) {
270+
contenttype := resp.Header.Get("Content-Type")
271+
if contenttype == "" {
272+
return ContentTypeBinary, nil
273+
}
274+
if mimetype, _, err := mime.ParseMediaType(contenttype); err != nil {
275+
return contenttype, ErrUnexpectedResponse.With(contenttype)
276+
} else {
277+
return mimetype, nil
278+
}
279+
}
280+
281+
// Remove any usernames and passwords before printing out
282+
func redactedUrl(url *url.URL) string {
283+
url_ := *url // make a copy
284+
url_.User = nil
285+
return url_.String()
286+
}

0 commit comments

Comments
 (0)