From 6844355a928b60ffb1006e6b1d9f8af7b44647d5 Mon Sep 17 00:00:00 2001 From: Julien Dessaux Date: Sun, 18 Jun 2023 17:41:38 +0200 Subject: Imported and modified evcli's api client code --- external/evcli/LICENSE | 13 ++ external/evcli/README.md | 3 + external/evcli/api.go | 86 ++++++++++++ external/evcli/client.go | 332 +++++++++++++++++++++++++++++++++++++++++++++ external/evcli/config.go | 6 + external/evcli/http.go | 14 ++ external/evcli/url.go | 42 ++++++ external/evcli/url_test.go | 23 ++++ 8 files changed, 519 insertions(+) create mode 100644 external/evcli/LICENSE create mode 100644 external/evcli/README.md create mode 100644 external/evcli/api.go create mode 100644 external/evcli/client.go create mode 100644 external/evcli/config.go create mode 100644 external/evcli/http.go create mode 100644 external/evcli/url.go create mode 100644 external/evcli/url_test.go (limited to 'external') diff --git a/external/evcli/LICENSE b/external/evcli/LICENSE new file mode 100644 index 0000000..eec6477 --- /dev/null +++ b/external/evcli/LICENSE @@ -0,0 +1,13 @@ +Copyright (c) 2022 Exograd SAS. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/external/evcli/README.md b/external/evcli/README.md new file mode 100644 index 0000000..91e7820 --- /dev/null +++ b/external/evcli/README.md @@ -0,0 +1,3 @@ +# evcli + +The code in this directory comes from [evcli](https://github.com/exograd/eventline/tree/master/cmd/evcli). It is the property of [Exograd](https://www.exograd.com/) under the [ISC License](https://github.com/exograd/eventline/blob/master/LICENSE). diff --git a/external/evcli/api.go b/external/evcli/api.go new file mode 100644 index 0000000..40fe38c --- /dev/null +++ b/external/evcli/api.go @@ -0,0 +1,86 @@ +package evcli + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/exograd/eventline/pkg/eventline" + "github.com/exograd/go-daemon/check" +) + +type APIError struct { + Message string `json:"error"` + Code string `json:"code,omitempty"` + RawData json.RawMessage `json:"data,omitempty"` + Data interface{} `json:"-"` +} + +type InvalidRequestBodyError struct { + ValidationErrors check.ValidationErrors `json:"validation_errors"` +} + +func (err APIError) Error() string { + return err.Message +} + +func (err *APIError) UnmarshalJSON(data []byte) error { + type APIError2 APIError + + err2 := APIError2(*err) + if jsonErr := json.Unmarshal(data, &err2); jsonErr != nil { + return jsonErr + } + + switch err2.Code { + case "invalid_request_body": + var errData InvalidRequestBodyError + + if err2.RawData != nil { + if err := json.Unmarshal(err2.RawData, &errData); err != nil { + return fmt.Errorf("invalid jsv errors: %w", err) + } + + err2.Data = &errData + } + } + + *err = APIError(err2) + return nil +} + +func IsInvalidRequestBodyError(err error) (bool, check.ValidationErrors) { + var apiError *APIError + + if !errors.As(err, &apiError) { + return false, nil + } + + requestBodyErr, ok := apiError.Data.(*InvalidRequestBodyError) + if !ok { + return false, nil + } + + return true, requestBodyErr.ValidationErrors +} + +type ProjectPage struct { + Elements eventline.Projects `json:"elements"` + Previous *eventline.Cursor `json:"previous,omitempty"` + Next *eventline.Cursor `json:"next,omitempty"` +} + +type Parameter struct { + Name string `json:"name"` + Type string `json:"type"` + Default interface{} `json:"default"` + Description string `json:"description"` +} + +type Parameters []*Parameter + +type JobPage struct { + Elements eventline.Jobs `json:"elements"` + Previous *eventline.Cursor `json:"previous,omitempty"` + Next *eventline.Cursor `json:"next,omitempty"` +} diff --git a/external/evcli/client.go b/external/evcli/client.go new file mode 100644 index 0000000..8127dff --- /dev/null +++ b/external/evcli/client.go @@ -0,0 +1,332 @@ +package evcli + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + + "github.com/exograd/eventline/pkg/eventline" +) + +type Client struct { + APIKey string + ProjectId *eventline.Id + + httpClient *http.Client + + baseURI *url.URL +} + +func NewClient(config *APIConfig) (*Client, error) { + baseURI, err := url.Parse(config.Endpoint) + if err != nil { + return nil, fmt.Errorf("invalid api endpoint: %w", err) + } + + client := &Client{ + APIKey: config.Key, + baseURI: baseURI, + httpClient: NewHTTPClient(), + } + + if err != nil { + } + + return client, nil +} + +func (c *Client) SendRequest(method string, relURI *url.URL, body, dest interface{}) error { + uri := c.baseURI.ResolveReference(relURI) + + var bodyReader io.Reader + if body == nil { + bodyReader = nil + } else if br, ok := body.(io.Reader); ok { + bodyReader = br + } else { + bodyData, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("cannot encode body: %w", err) + } + + bodyReader = bytes.NewReader(bodyData) + } + + req, err := http.NewRequest(method, uri.String(), bodyReader) + if err != nil { + return fmt.Errorf("cannot create request: %w", err) + } + + if c.APIKey != "" { + req.Header.Set("Authorization", "Bearer "+c.APIKey) + } + + if c.ProjectId != nil { + req.Header.Set("X-Eventline-Project-Id", c.ProjectId.String()) + } + + res, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("cannot send request: %w", err) + } + defer res.Body.Close() + + resBody, err := ioutil.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("cannot read response body: %w", err) + } + + if res.StatusCode < 200 || res.StatusCode >= 300 { + var apiErr APIError + + err := json.Unmarshal(resBody, &apiErr) + if err == nil { + return &apiErr + } + + return fmt.Errorf("request failed with status %d: %s", + res.StatusCode, string(resBody)) + } + + if dest != nil { + if dataPtr, ok := dest.(*[]byte); ok { + *dataPtr = resBody + } else { + if len(resBody) == 0 { + return fmt.Errorf("empty response body") + } + + if err := json.Unmarshal(resBody, dest); err != nil { + return fmt.Errorf("cannot decode response body: %w", err) + } + } + } + + return err +} + +func (c *Client) FetchProjects() (eventline.Projects, error) { + var projects eventline.Projects + + cursor := eventline.Cursor{Size: 1} + + for { + var page ProjectPage + + uri := NewURL("projects") + uri.RawQuery = cursor.Query().Encode() + + err := c.SendRequest("GET", uri, nil, &page) + if err != nil { + return nil, err + } + + projects = append(projects, page.Elements...) + + if page.Next == nil { + break + } + + cursor = *page.Next + } + + return projects, nil +} + +func (c *Client) FetchProjectById(id eventline.Id) (*eventline.Project, error) { + uri := NewURL("projects", "id", id.String()) + + var project eventline.Project + + err := c.SendRequest("GET", uri, nil, &project) + if err != nil { + return nil, err + } + + return &project, nil +} + +func (c *Client) FetchProjectByName(name string) (*eventline.Project, error) { + uri := NewURL("projects", "name", name) + + var project eventline.Project + + err := c.SendRequest("GET", uri, nil, &project) + if err != nil { + return nil, err + } + + return &project, nil +} + +func (c *Client) CreateProject(project *eventline.Project) error { + uri := NewURL("projects") + + return c.SendRequest("POST", uri, project, project) +} + +func (c *Client) DeleteProject(id eventline.Id) error { + uri := NewURL("projects", "id", id.String()) + + return c.SendRequest("DELETE", uri, nil, nil) +} + +func (c *Client) UpdateProject(project *eventline.Project) error { + uri := NewURL("projects", "id", project.Id.String()) + + return c.SendRequest("PUT", uri, project, nil) +} + +func (c *Client) ReplayEvent(id string) (*eventline.Event, error) { + var event eventline.Event + + uri := NewURL("events", "id", id, "replay") + + err := c.SendRequest("POST", uri, nil, &event) + if err != nil { + return nil, err + } + + return &event, nil +} + +func (c *Client) FetchJobByName(name string) (*eventline.Job, error) { + uri := NewURL("jobs", "name", name) + + var job eventline.Job + + err := c.SendRequest("GET", uri, nil, &job) + if err != nil { + return nil, err + } + + return &job, nil +} + +func (c *Client) FetchJobs() (eventline.Jobs, error) { + var jobs eventline.Jobs + + cursor := eventline.Cursor{Size: 20} + + for { + var page JobPage + + uri := NewURL("jobs") + uri.RawQuery = cursor.Query().Encode() + + err := c.SendRequest("GET", uri, nil, &page) + if err != nil { + return nil, err + } + + jobs = append(jobs, page.Elements...) + + if page.Next == nil { + break + } + + cursor = *page.Next + } + + return jobs, nil +} + +func (c *Client) DeployJob(spec *eventline.JobSpec, dryRun bool) (*eventline.Job, error) { + uri := NewURL("jobs", "name", spec.Name) + + query := url.Values{} + if dryRun { + query.Add("dry-run", "") + } + uri.RawQuery = query.Encode() + + if dryRun { + if err := c.SendRequest("PUT", uri, spec, nil); err != nil { + return nil, err + } + + return nil, nil + } else { + var job eventline.Job + + if err := c.SendRequest("PUT", uri, spec, &job); err != nil { + return nil, err + } + + return &job, nil + } + +} + +func (c *Client) DeployJobs(specs []*eventline.JobSpec, dryRun bool) ([]*eventline.Job, error) { + uri := NewURL("jobs") + + query := url.Values{} + if dryRun { + query.Add("dry-run", "") + } + uri.RawQuery = query.Encode() + + if dryRun { + if err := c.SendRequest("PUT", uri, specs, nil); err != nil { + return nil, err + } + + return nil, nil + } else { + var jobs []*eventline.Job + + if err := c.SendRequest("PUT", uri, specs, &jobs); err != nil { + return nil, err + } + + return jobs, nil + } +} + +func (c *Client) DeleteJob(id string) error { + uri := NewURL("jobs", "id", id) + + return c.SendRequest("DELETE", uri, nil, nil) +} + +func (c *Client) ExecuteJob(id string, input *eventline.JobExecutionInput) (*eventline.JobExecution, error) { + uri := NewURL("jobs", "id", id, "execute") + + var jobExecution eventline.JobExecution + + if err := c.SendRequest("POST", uri, input, &jobExecution); err != nil { + return nil, err + } + + return &jobExecution, nil +} + +func (c *Client) FetchJobExecution(id eventline.Id) (*eventline.JobExecution, error) { + uri := NewURL("job_executions", "id", id.String()) + + var je eventline.JobExecution + + err := c.SendRequest("GET", uri, nil, &je) + if err != nil { + return nil, err + } + + return &je, nil +} + +func (c *Client) AbortJobExecution(id eventline.Id) error { + uri := NewURL("job_executions", "id", id.String(), "abort") + + return c.SendRequest("POST", uri, nil, nil) +} + +func (c *Client) RestartJobExecution(id eventline.Id) error { + uri := NewURL("job_executions", "id", id.String(), "restart") + + return c.SendRequest("POST", uri, nil, nil) +} diff --git a/external/evcli/config.go b/external/evcli/config.go new file mode 100644 index 0000000..df20c45 --- /dev/null +++ b/external/evcli/config.go @@ -0,0 +1,6 @@ +package evcli + +type APIConfig struct { + Endpoint string `json:"endpoint,omitempty"` + Key string `json:"key,omitempty"` +} diff --git a/external/evcli/http.go b/external/evcli/http.go new file mode 100644 index 0000000..f4aae1c --- /dev/null +++ b/external/evcli/http.go @@ -0,0 +1,14 @@ +package evcli + +import ( + "net/http" + "time" +) + +func NewHTTPClient() *http.Client { + c := &http.Client{ + Timeout: 30 * time.Second, + } + + return c +} diff --git a/external/evcli/url.go b/external/evcli/url.go new file mode 100644 index 0000000..e1e99a8 --- /dev/null +++ b/external/evcli/url.go @@ -0,0 +1,42 @@ +package evcli + +import ( + "bytes" + "net/url" +) + +// The url package has an extremely confusing interface. One could believe +// setting RawPath is enough for it to be used during encoding, but this is +// not the case. Instead, *both* must be set, and (*url.URL).EscapedPath will +// check that RawPath is a valid encoding of RawPath. If this is not the case, +// RawPath will be ignored. +// +// In most cases, the problem is not apparent. But if the path contains +// segments with slash or space characters, it is almost guaranteed to misuse +// Path and RawPath and double-encode these characters. +// +// The problem was signaled years ago on +// https://github.com/golang/go/issues/17340 but was of course ignored and +// buried. +// +// As usual with standard library issues, the only thing we can do is add +// utils to work around it. + +func NewURL(pathSegments ...string) *url.URL { + var buf bytes.Buffer + buf.WriteByte('/') + for i, s := range pathSegments { + if i > 0 { + buf.WriteByte('/') + } + buf.WriteString(url.PathEscape(s)) + } + rawPath := buf.String() + + path, _ := url.PathUnescape(rawPath) + + return &url.URL{ + Path: path, + RawPath: rawPath, + } +} diff --git a/external/evcli/url_test.go b/external/evcli/url_test.go new file mode 100644 index 0000000..aa13281 --- /dev/null +++ b/external/evcli/url_test.go @@ -0,0 +1,23 @@ +package evcli + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewURL(t *testing.T) { + assert := assert.New(t) + + assert.Equal("/", + NewURL().String()) + + assert.Equal("/a", + NewURL("a").String()) + + assert.Equal("/a/bcd/ef", + NewURL("a", "bcd", "ef").String()) + + assert.Equal("/a/b%20c/e%2Ff", + NewURL("a", "b c", "e/f").String()) +} -- cgit v1.2.3