From 37e993b676f489603be18e73dcb01571c745695b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81ngel=20P=C3=A9rez?= Date: Mon, 31 Jul 2023 08:55:15 +0200 Subject: [PATCH] client: Export public API. This patch, similar in spirit to #238, introduces a new public API for extending the Pebble client. The basic idea behind this patch is the client mixin struct embedded in commands' structs no longer holds an instance of the Pebble-specific `client.Client` struct, but instead take a `ClientGetter` interface that implements a `Client()` method. `ClientGetter.Client()` always returns a Pebble-specific `client.Client` struct. For applications that indent to extend Pebble, this means that either the Pebble client or a new, application-specific client can be used. In the latter case, the application-specific client must implement the `ClientGetter` interface so that a Pebble-specific `client.Client` struct can always be derived by the facilities consuming the `ClientGetter` interface. The easiest way to implement this is to embed the Pebble client: type PebbleClient = client.Client type MyClient struct { *PebbleClient } Since the Pebble-specific `client.Client` is embedded, and the `ClientGetter` interface requires `Client()` to be implemented with a pointer receiver, the snippet above suffices to implement a client based off the Pebble-supplied one without much hassle, and that provides lower-level facilities for communicating with the daemon, such as `DoSync()`, `DoAsync()` and `DoAsyncFull()`. --- client/changes.go | 24 ++++++++--- client/checks.go | 6 ++- client/client.go | 74 ++++++++++++++++++++++++++-------- client/exec.go | 7 +++- client/export_test.go | 5 ++- client/files.go | 20 +++++++-- client/plan.go | 12 +++++- client/services.go | 13 +++++- client/signals.go | 6 ++- client/warnings.go | 12 +++++- internals/cli/cli.go | 58 +++++++++++++------------- internals/cli/cmd_add.go | 4 +- internals/cli/cmd_autostart.go | 2 +- internals/cli/cmd_changes.go | 6 +-- internals/cli/cmd_checks.go | 4 +- internals/cli/cmd_enter.go | 6 +-- internals/cli/cmd_exec.go | 4 +- internals/cli/cmd_logs.go | 6 +-- internals/cli/cmd_ls.go | 4 +- internals/cli/cmd_mkdir.go | 4 +- internals/cli/cmd_plan.go | 4 +- internals/cli/cmd_replan.go | 2 +- internals/cli/cmd_restart.go | 2 +- internals/cli/cmd_rm.go | 4 +- internals/cli/cmd_run.go | 6 +-- internals/cli/cmd_services.go | 4 +- internals/cli/cmd_signal.go | 4 +- internals/cli/cmd_start.go | 2 +- internals/cli/cmd_stop.go | 2 +- internals/cli/cmd_version.go | 8 ++-- internals/cli/cmd_warnings.go | 8 ++-- internals/cli/last.go | 4 +- internals/cli/wait.go | 6 +-- 33 files changed, 222 insertions(+), 111 deletions(-) diff --git a/client/changes.go b/client/changes.go index 47fdfd84..f27813a1 100644 --- a/client/changes.go +++ b/client/changes.go @@ -89,8 +89,10 @@ type changeAndData struct { // Change fetches information about a Change given its ID. func (client *Client) Change(id string) (*Change, error) { var chgd changeAndData - _, err := client.doSync("GET", "/v1/changes/"+id, nil, nil, nil, &chgd) - if err != nil { + if _, err := client.DoSync(&RequestInfo{ + Method: "GET", + Path: "/v1/changes/" + id, + }, &chgd); err != nil { return nil, err } @@ -111,7 +113,11 @@ func (client *Client) Abort(id string) (*Change, error) { } var chg Change - if _, err := client.doSync("POST", "/v1/changes/"+id, nil, nil, &body, &chg); err != nil { + if _, err := client.DoSync(&RequestInfo{ + Method: "POST", + Path: "/v1/changes/" + id, + Body: &body, + }, &chg); err != nil { return nil, err } @@ -158,7 +164,11 @@ func (client *Client) Changes(opts *ChangesOptions) ([]*Change, error) { } var chgds []changeAndData - _, err := client.doSync("GET", "/v1/changes", query, nil, nil, &chgds) + _, err := client.DoSync(&RequestInfo{ + Method: "GET", + Path: "/v1/changes", + Query: query, + }, &chgds) if err != nil { return nil, err } @@ -190,7 +200,11 @@ func (client *Client) WaitChange(id string, opts *WaitChangeOptions) (*Change, e query.Set("timeout", opts.Timeout.String()) } - _, err := client.doSync("GET", "/v1/changes/"+id+"/wait", query, nil, nil, &chgd) + _, err := client.DoSync(&RequestInfo{ + Method: "GET", + Path: "/v1/changes/" + id + "/wait", + Query: query, + }, &chgd) if err != nil { return nil, err } diff --git a/client/checks.go b/client/checks.go index b86d656b..b52315ac 100644 --- a/client/checks.go +++ b/client/checks.go @@ -79,7 +79,11 @@ func (client *Client) Checks(opts *ChecksOptions) ([]*CheckInfo, error) { query["names"] = opts.Names } var checks []*CheckInfo - _, err := client.doSync("GET", "/v1/checks", query, nil, nil, &checks) + _, err := client.DoSync(&RequestInfo{ + Method: "GET", + Path: "/v1/checks", + Query: query, + }, &checks) if err != nil { return nil, err } diff --git a/client/client.go b/client/client.go index 0e9cce91..03948710 100644 --- a/client/client.go +++ b/client/client.go @@ -87,6 +87,17 @@ type doer interface { Do(*http.Request) (*http.Response, error) } +// ClientGetter implementations must provide a way to convert themselves to +// Pebble client instances. +type ClientGetter interface { + // Client returns a Pebble client instance. + Client() *Client +} + +type ClientSetter interface { + SetClient(ClientGetter) +} + // Config allows the user to customize client behavior. type Config struct { // BaseURL contains the base URL where the Pebble daemon is expected to be. @@ -118,6 +129,10 @@ type Client struct { getWebsocket getWebsocketFunc } +func (c *Client) Client() *Client { + return c +} + type getWebsocketFunc func(url string) (clientWebsocket, error) type clientWebsocket interface { @@ -326,13 +341,27 @@ func decodeInto(reader io.Reader, v interface{}) error { return nil } -// doSync performs a request to the given path using the specified HTTP method. +// RequestInfo holds the information to perform a request to the daemon. +type RequestInfo struct { + Method string + Path string + Query url.Values + Headers map[string]string + Body io.Reader +} + +// ResultInfo is empty for now, but this is the mechanism that conveys +// general information that makes sense to requests at a more general +// level, and might be disconnected from the specific request at hand. +type ResultInfo struct{} + +// DoSync performs a request to the given path using the specified HTTP method. // It expects a "sync" response from the API and on success decodes the JSON // response payload into the given value using the "UseNumber" json decoding // which produces json.Numbers instead of float64 types for numbers. -func (client *Client) doSync(method, path string, query url.Values, headers map[string]string, body io.Reader, v interface{}) (*ResultInfo, error) { +func (client *Client) DoSync(req *RequestInfo, v interface{}) (*ResultInfo, error) { var rsp response - if err := client.do(method, path, query, headers, body, &rsp); err != nil { + if err := client.do(req.Method, req.Path, req.Query, req.Headers, req.Body, &rsp); err != nil { return nil, err } if err := rsp.err(client); err != nil { @@ -354,22 +383,28 @@ func (client *Client) doSync(method, path string, query url.Values, headers map[ return &rsp.ResultInfo, nil } -func (client *Client) doAsync(method, path string, query url.Values, headers map[string]string, body io.Reader) (changeID string, err error) { - _, changeID, err = client.doAsyncFull(method, path, query, headers, body) +// DoAsync performs a request to the given path using the specified HTTP method. +// It expects an "async" response from the API and on success returns the +// change ID. +func (client *Client) DoAsync(req *RequestInfo) (changeID string, err error) { + _, changeID, err = client.DoAsyncFull(req) return } -func (client *Client) doAsyncFull(method, path string, query url.Values, headers map[string]string, body io.Reader) (result json.RawMessage, changeID string, err error) { +// DoAsync performs a request to the given path using the specified HTTP method. +// It expects an "async" response from the API and on success returns the raw +// JSON response from the daemon alongside the change ID. +func (client *Client) DoAsyncFull(req *RequestInfo) (result json.RawMessage, changeID string, err error) { var rsp response - if err := client.do(method, path, query, headers, body, &rsp); err != nil { + if err := client.do(req.Method, req.Path, req.Query, req.Headers, req.Body, &rsp); err != nil { return nil, "", err } if err := rsp.err(client); err != nil { return nil, "", err } if rsp.Type != "async" { - return nil, "", fmt.Errorf("expected async response for %q on %q, got %q", method, path, rsp.Type) + return nil, "", fmt.Errorf("expected async response for %q on %q, got %q", req.Method, req.Path, rsp.Type) } if rsp.StatusCode != 202 { return nil, "", fmt.Errorf("operation not accepted") @@ -381,11 +416,6 @@ func (client *Client) doAsyncFull(method, path string, query url.Values, headers return rsp.Result, rsp.Change, nil } -// ResultInfo is empty for now, but this is the mechanism that conveys -// general information that makes sense to requests at a more general -// level, and might be disconnected from the specific request at hand. -type ResultInfo struct{} - // A response produced by the REST API will usually fit in this // (exceptions are the icons/ endpoints obvs) type response struct { @@ -476,7 +506,10 @@ type SysInfo struct { func (client *Client) SysInfo() (*SysInfo, error) { var sysInfo SysInfo - if _, err := client.doSync("GET", "/v1/system-info", nil, nil, nil, &sysInfo); err != nil { + if _, err := client.DoSync(&RequestInfo{ + Method: "GET", + Path: "/v1/system-info", + }, &sysInfo); err != nil { return nil, fmt.Errorf("cannot obtain system details: %w", err) } @@ -497,8 +530,11 @@ func (client *Client) DebugPost(action string, params interface{}, result interf if err != nil { return err } - - _, err = client.doSync("POST", "/v1/debug", nil, nil, bytes.NewReader(body), result) + _, err = client.DoSync(&RequestInfo{ + Method: "POST", + Path: "/v1/debug", + Body: bytes.NewReader(body), + }, result) return err } @@ -508,6 +544,10 @@ func (client *Client) DebugGet(action string, result interface{}, params map[str for k, v := range params { urlParams.Set(k, v) } - _, err := client.doSync("GET", "/v1/debug", urlParams, nil, nil, &result) + _, err := client.DoSync(&RequestInfo{ + Method: "GET", + Path: "/v1/debug", + Query: urlParams, + }, &result) return err } diff --git a/client/exec.go b/client/exec.go index 88d405bd..32ca7952 100644 --- a/client/exec.go +++ b/client/exec.go @@ -152,7 +152,12 @@ func (client *Client) Exec(opts *ExecOptions) (*ExecProcess, error) { headers := map[string]string{ "Content-Type": "application/json", } - resultBytes, changeID, err := client.doAsyncFull("POST", "/v1/exec", nil, headers, &body) + resultBytes, changeID, err := client.DoAsyncFull(&RequestInfo{ + Method: "POST", + Path: "/v1/exec", + Headers: headers, + Body: &body, + }) if err != nil { return nil, err } diff --git a/client/export_test.go b/client/export_test.go index ec509df2..13f6dd2c 100644 --- a/client/export_test.go +++ b/client/export_test.go @@ -34,7 +34,10 @@ func (client *Client) Do(method, path string, query url.Values, body io.Reader, } func (client *Client) FakeAsyncRequest() (changeId string, err error) { - changeId, err = client.doAsync("GET", "/v1/async-test", nil, nil, nil) + changeId, err = client.DoAsync(&RequestInfo{ + Method: "GET", + Path: "/v1/async-test", + }) if err != nil { return "", fmt.Errorf("cannot do async test: %v", err) } diff --git a/client/files.go b/client/files.go index cce33414..1b47fcc3 100644 --- a/client/files.go +++ b/client/files.go @@ -121,7 +121,11 @@ func (client *Client) ListFiles(opts *ListFilesOptions) ([]*FileInfo, error) { } var results []fileInfoResult - _, err := client.doSync("GET", "/v1/files", q, nil, nil, &results) + _, err := client.DoSync(&RequestInfo{ + Method: "GET", + Path: "/v1/files", + Query: q, + }, &results) if err != nil { return nil, err } @@ -280,7 +284,12 @@ func (client *Client) MakeDir(opts *MakeDirOptions) error { headers := map[string]string{ "Content-Type": "application/json", } - if _, err := client.doSync("POST", "/v1/files", nil, headers, &body, &result); err != nil { + if _, err := client.DoSync(&RequestInfo{ + Method: "POST", + Path: "/v1/files", + Headers: headers, + Body: &body, + }, &result); err != nil { return err } @@ -347,7 +356,12 @@ func (client *Client) RemovePath(opts *RemovePathOptions) error { headers := map[string]string{ "Content-Type": "application/json", } - if _, err := client.doSync("POST", "/v1/files", nil, headers, &body, &result); err != nil { + if _, err := client.DoSync(&RequestInfo{ + Method: "POST", + Path: "/v1/files", + Headers: headers, + Body: &body, + }, &result); err != nil { return err } diff --git a/client/plan.go b/client/plan.go index 25b447f0..f1efd1b6 100644 --- a/client/plan.go +++ b/client/plan.go @@ -52,7 +52,11 @@ func (client *Client) AddLayer(opts *AddLayerOptions) error { if err := json.NewEncoder(&body).Encode(&payload); err != nil { return err } - _, err := client.doSync("POST", "/v1/layers", nil, nil, &body, nil) + _, err := client.DoSync(&RequestInfo{ + Method: "POST", + Path: "/v1/layers", + Body: &body, + }, nil) return err } @@ -64,7 +68,11 @@ func (client *Client) PlanBytes(_ *PlanOptions) (data []byte, err error) { "format": []string{"yaml"}, } var dataStr string - _, err = client.doSync("GET", "/v1/plan", query, nil, nil, &dataStr) + _, err = client.DoSync(&RequestInfo{ + Method: "GET", + Path: "/v1/plan", + Query: query, + }, &dataStr) if err != nil { return nil, err } diff --git a/client/services.go b/client/services.go index 0be19d23..0368b0b5 100644 --- a/client/services.go +++ b/client/services.go @@ -77,7 +77,12 @@ func (client *Client) doMultiServiceAction(actionName string, services []string) headers := map[string]string{ "Content-Type": "application/json", } - return client.doAsyncFull("POST", "/v1/services", nil, headers, bytes.NewBuffer(data)) + return client.DoAsyncFull(&RequestInfo{ + Method: "POST", + Path: "/v1/services", + Headers: headers, + Body: bytes.NewBuffer(data), + }) } type ServicesOptions struct { @@ -119,7 +124,11 @@ func (client *Client) Services(opts *ServicesOptions) ([]*ServiceInfo, error) { "names": []string{strings.Join(opts.Names, ",")}, } var services []*ServiceInfo - _, err := client.doSync("GET", "/v1/services", query, nil, nil, &services) + _, err := client.DoSync(&RequestInfo{ + Method: "GET", + Path: "/v1/services", + Query: query, + }, &services) if err != nil { return nil, err } diff --git a/client/signals.go b/client/signals.go index 9452ad4f..d3e4efb4 100644 --- a/client/signals.go +++ b/client/signals.go @@ -36,7 +36,11 @@ func (client *Client) SendSignal(opts *SendSignalOptions) error { if err != nil { return fmt.Errorf("cannot encode JSON payload: %w", err) } - _, err = client.doSync("POST", "/v1/signals", nil, nil, &body, nil) + _, err = client.DoSync(&RequestInfo{ + Method: "POST", + Path: "/v1/signals", + Body: &body, + }, nil) return err } diff --git a/client/warnings.go b/client/warnings.go index d63da123..a8cfae82 100644 --- a/client/warnings.go +++ b/client/warnings.go @@ -52,7 +52,11 @@ func (client *Client) Warnings(opts WarningsOptions) ([]*Warning, error) { if opts.All { q.Add("select", "all") } - _, err := client.doSync("GET", "/v1/warnings", q, nil, nil, &jws) + _, err := client.DoSync(&RequestInfo{ + Method: "GET", + Path: "/v1/warnings", + Query: q, + }, &jws) ws := make([]*Warning, len(jws)) for i, jw := range jws { @@ -77,6 +81,10 @@ func (client *Client) Okay(t time.Time) error { if err := json.NewEncoder(&body).Encode(op); err != nil { return err } - _, err := client.doSync("POST", "/v1/warnings", nil, nil, &body, nil) + _, err := client.DoSync(&RequestInfo{ + Method: "POST", + Path: "/v1/warnings", + Body: &body, + }, nil) return err } diff --git a/internals/cli/cli.go b/internals/cli/cli.go index 18a37851..2f8f5a41 100644 --- a/internals/cli/cli.go +++ b/internals/cli/cli.go @@ -157,24 +157,12 @@ func fixupArg(optName string) string { return optName } -type clientSetter interface { - setClient(*client.Client) -} - -type clientMixin struct { - client *client.Client -} - -func (ch *clientMixin) setClient(cli *client.Client) { - ch.client = cli -} - // Parser creates and populates a fresh parser. // Since commands have local state a fresh parser is required to isolate tests // from each other. -func Parser(cli *client.Client) *flags.Parser { +func Parser(cg client.ClientGetter) *flags.Parser { optionsData.Version = func() { - printVersions(cli) + printVersions(cg) panic(&exitStatus{0}) } flagopts := flags.Options(flags.PassDoubleDash) @@ -193,8 +181,8 @@ func Parser(cli *client.Client) *flags.Parser { // Add all regular commands for _, c := range commands { obj := c.builder() - if x, ok := obj.(clientSetter); ok { - x.setClient(cli) + if x, ok := obj.(client.ClientSetter); ok { + x.SetClient(cg) } if x, ok := obj.(parserSetter); ok { x.setParser(parser) @@ -256,8 +244,8 @@ func Parser(cli *client.Client) *flags.Parser { // Add all the sub-commands of the debug command for _, c := range debugCommands { obj := c.builder() - if x, ok := obj.(clientSetter); ok { - x.setClient(cli) + if x, ok := obj.(client.ClientSetter); ok { + x.SetClient(cg) } cmd, err := debugCommand.AddCommand(c.name, c.shortHelp, strings.TrimSpace(c.longHelp), obj) if err != nil { @@ -323,7 +311,30 @@ func (e *exitStatus) Error() string { return fmt.Sprintf("internal error: exitStatus{%d} being handled as normal error", e.code) } +// ClientMixin is embedded in the structs of commands that require a client +// instance in order to communicate with the daemon. +type ClientMixin struct { + client.ClientGetter +} + +func (cm *ClientMixin) SetClient(cg client.ClientGetter) { + cm.ClientGetter = cg +} + func Run() error { + _, clientConfig.Socket = getEnvPaths() + + cli, err := client.New(&clientConfig) + if err != nil { + return fmt.Errorf("cannot create client: %v", err) + } + + err = RunWithClient(cli.Client()) + maybePresentWarnings(cli.WarningsSummary()) + return err +} + +func RunWithClient(cg client.ClientGetter) error { defer func() { if v := recover(); v != nil { if e, ok := v.(*exitStatus); ok { @@ -335,14 +346,7 @@ func Run() error { logger.SetLogger(logger.New(os.Stderr, "[pebble] ")) - _, clientConfig.Socket = getEnvPaths() - - cli, err := client.New(&clientConfig) - if err != nil { - return fmt.Errorf("cannot create client: %v", err) - } - - parser := Parser(cli) + parser := Parser(cg) xtra, err := parser.Parse() if err != nil { if e, ok := err.(*flags.Error); ok { @@ -375,8 +379,6 @@ func Run() error { return nil } - maybePresentWarnings(cli.WarningsSummary()) - return nil } diff --git a/internals/cli/cmd_add.go b/internals/cli/cmd_add.go index 521f1c4c..d0890844 100644 --- a/internals/cli/cmd_add.go +++ b/internals/cli/cmd_add.go @@ -24,7 +24,7 @@ import ( ) type cmdAdd struct { - clientMixin + ClientMixin Combine bool `long:"combine"` Positional struct { Label string `positional-arg-name:"