Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ Chaitin Workspace CLI for products

[![asciicast](https://asciinema.org/a/dzJzibRTm8arWRmU.svg)](https://asciinema.org/a/dzJzibRTm8arWRmU)

### DDR

[![asciicast](https://asciinema.org/a/IHulIbJ5nsy924qd.svg)](https://asciinema.org/a/t0cFuOjkLkExjREx)

### X-Ray

[![asciicast](https://asciinema.org/a/923077.svg)](https://asciinema.org/a/923077)
Expand All @@ -42,18 +46,26 @@ tanswer:
url: https://tanswer.example.com
api_key: YOUR_API_KEY

# cws ddr get-api-token --url https://ddr.example.com:8443 --jwt-token "YOUR_JWT_TOKEN" can directly get url & api_key & company_id
ddr:
url: "https://ddr.example.com:8443/qzh/api/v1"
api_key: "YOUR_API_KEY"
company_id: "YOUR_COMPANY_ID"

xray:
url: https://xray.example.com/api/v2
api_key: YOUR_API_KEY
```

You can also put the same keys into environment variables or a local `.env` file. Variable names follow `<PRODUCT>_<FIELD>`:

```text
cloudwalker.url -> CLOUDWALKER_URL
cloudwalker.api_key -> CLOUDWALKER_API_KEY
tanswer.url -> TANSWER_URL
tanswer.api_key -> TANSWER_API_KEY
ddr.url -> DDR_URL
ddr.api_key -> DDR_API_KEY
ddr.company_id -> DDR_COMPANY_ID
xray.url -> XRAY_URL
xray.api_key -> XRAY_API_KEY
safeline-ce.url -> SAFELINE_CE_URL
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@

[![asciicast](https://asciinema.org/a/dzJzibRTm8arWRmU.svg)](https://asciinema.org/a/dzJzibRTm8arWRmU)

### DDR

[![asciicast](https://asciinema.org/a/IHulIbJ5nsy924qd.svg)](https://asciinema.org/a/t0cFuOjkLkExjREx)

### X-Ray

[![asciicast](https://asciinema.org/a/923077.svg)](https://asciinema.org/a/923077)
Expand All @@ -43,18 +47,26 @@ tanswer:
url: https://tanswer.example.com
api_key: YOUR_API_KEY

# cws ddr get-api-token --url https://ddr.example.com:8443 --jwt-token "YOUR_JWT_TOKEN" 可以直接获取 url & api_key & company_id
ddr:
url: "https://ddr.example.com:8443/qzh/api/v1"
api_key: "YOUR_API_KEY"
company_id: "YOUR_COMPANY_ID"

xray:
url: https://xray.example.com/api/v2
api_key: YOUR_API_KEY
```

也可以把同样的配置放到环境变量或本地 `.env` 文件中。变量命名规则为 `<PRODUCT>_<FIELD>`:

```text
cloudwalker.url -> CLOUDWALKER_URL
cloudwalker.api_key -> CLOUDWALKER_API_KEY
tanswer.url -> TANSWER_URL
tanswer.api_key -> TANSWER_API_KEY
ddr.url -> DDR_URL
ddr.api_key -> DDR_API_KEY
ddr.company_id -> DDR_COMPANY_ID
xray.url -> XRAY_URL
xray.api_key -> XRAY_API_KEY
safeline-ce.url -> SAFELINE_CE_URL
Expand Down
5 changes: 5 additions & 0 deletions config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,8 @@ safeline-ce:
safeline:
url: ""
api_key: ""

ddr:
url: ""
api_key: ""
company_id: ""
32 changes: 32 additions & 0 deletions config/write.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package config

import (
"fmt"
"os"

"gopkg.in/yaml.v3"
)

func SetProduct(path, name string, value any) error {
cfg, err := Load(path)
if err != nil {
return err
}

var node yaml.Node
if err := node.Encode(value); err != nil {
return fmt.Errorf("encode config for %s: %w", name, err)
}
cfg[name] = node

data, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("marshal config file %s: %w", path, err)
}

if err := os.WriteFile(path, data, 0o600); err != nil {
return fmt.Errorf("write config file %s: %w", path, err)
}

return nil
}
42 changes: 42 additions & 0 deletions config/write_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package config

import (
"path/filepath"
"testing"
)

func TestSetProduct(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")

if err := SetProduct(path, "ddr", struct {
URL string `yaml:"url"`
APIKey string `yaml:"api_key"`
CompanyID string `yaml:"company_id"`
}{
URL: "https://example.com",
APIKey: "Serval token",
CompanyID: "corp-1",
}); err != nil {
t.Fatalf("SetProduct() error = %v", err)
}

cfg, err := Load(path)
if err != nil {
t.Fatalf("Load() error = %v", err)
}

var got struct {
URL string `yaml:"url"`
APIKey string `yaml:"api_key"`
CompanyID string `yaml:"company_id"`
}
node := cfg["ddr"]
if err := node.Decode(&got); err != nil {
t.Fatalf("Decode() error = %v", err)
}

if got.URL != "https://example.com" || got.APIKey != "Serval token" || got.CompanyID != "corp-1" {
t.Fatalf("unexpected config: %+v", got)
}
}
4 changes: 4 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/chaitin/workspace-cli/config"
"github.com/chaitin/workspace-cli/products/chaitin"
"github.com/chaitin/workspace-cli/products/cloudwalker"
"github.com/chaitin/workspace-cli/products/ddr"
"github.com/chaitin/workspace-cli/products/safeline"
safelinece "github.com/chaitin/workspace-cli/products/safeline-ce"
"github.com/chaitin/workspace-cli/products/tanswer"
Expand Down Expand Up @@ -47,6 +48,7 @@ func newApp() (*app, error) {
a.registerProductCommand(chaitin.NewCommand())
a.registerProductCommand(safelinece.NewCommand())
a.registerProductCommand(cloudwalker.NewCommand())
a.registerProductCommand(ddr.NewCommand())
a.registerProductCommand(tanswer.NewCommand())

xrayCmd, err := xray.NewCommand()
Expand Down Expand Up @@ -107,6 +109,8 @@ func (a *app) wrapProductCommand(cmd *cobra.Command) {
safelinece.ApplyRuntimeConfig(command, a.config, a.dryRun)
case "cloudwalker":
cloudwalker.ApplyRuntimeConfig(command, a.config)
case "ddr":
ddr.ApplyRuntimeConfig(command, a.config, a.configPath, a.dryRun)
case "tanswer":
tanswer.ApplyRuntimeConfig(command, a.config)
case "xray":
Expand Down
154 changes: 154 additions & 0 deletions products/ddr/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package ddr

import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)

type Client struct {
config *Config
headers map[string]string
httpClient *http.Client
baseURL string
verbose bool
}

func NewClient(cfg *Config, headers map[string]string, verbose bool) *Client {
return &Client{
config: cfg,
headers: headers,
httpClient: &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
},
baseURL: strings.TrimSuffix(cfg.URL, "/"),
verbose: verbose,
}
}

func (c *Client) Do(ctx context.Context, method, path string, query url.Values, headers map[string]string, body, result interface{}) error {
reqURL := c.buildURL(path)
if len(query) > 0 {
reqURL += "?" + query.Encode()
}

var reqBody io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to marshal request body: %w", err)
}
reqBody = bytes.NewReader(data)
}

req, err := http.NewRequestWithContext(ctx, method, reqURL, reqBody)
if err != nil {
return NewNetworkError("failed to create request", err)
}

c.injectHeaders(req, headers, body != nil)
if c.verbose {
logRequest(req, body)
}

if dryRun {
return renderDryRun(req, body, c.verbose)
}

resp, err := c.httpClient.Do(req)
if err != nil {
return NewNetworkError("request failed", err)
}
defer resp.Body.Close()

return c.handleResponse(resp, result)
}

func (c *Client) buildURL(path string) string {
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
return path
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return c.baseURL + path
}

func (c *Client) injectHeaders(req *http.Request, headers map[string]string, hasBody bool) {
authInfo := c.config.APIKey
if !strings.HasPrefix(authInfo, "Serval ") && !strings.HasPrefix(authInfo, "Bearer ") {
authInfo = "Serval " + authInfo
}
req.Header.Set("Authorization", authInfo)
req.Header.Set("X-CS-Header-Company", c.config.CompanyID)
for key, value := range c.headers {
if value == "" {
continue
}
req.Header.Set(key, value)
}
for key, value := range headers {
if value == "" {
continue
}
req.Header.Set(key, value)
}
if hasBody && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
}

func (c *Client) handleResponse(resp *http.Response, result interface{}) error {
body, err := io.ReadAll(resp.Body)
if err != nil {
return NewNetworkError("failed to read response body", err)
}

if resp.StatusCode >= 400 {
return NewAPIError(resp.StatusCode, fmt.Sprintf("API request failed (status %d): %s", resp.StatusCode, strings.TrimSpace(string(body))))
}

if result != nil && len(body) > 0 {
if err := json.Unmarshal(body, result); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
}

return nil
}

func renderDryRun(req *http.Request, body interface{}, alreadyLogged bool) error {
if !alreadyLogged {
logRequest(req, body)
}
return nil
}

func logRequest(req *http.Request, body interface{}) {
fmt.Fprintf(os.Stderr, "URL: %s %s\n", req.Method, req.URL.String())
if len(req.Header) > 0 {
data, err := json.MarshalIndent(req.Header, "", " ")
if err == nil {
fmt.Fprintf(os.Stderr, "Headers:\n%s\n", string(data))
}
}
if body != nil {
data, err := json.MarshalIndent(body, "", " ")
if err == nil {
fmt.Fprintf(os.Stderr, "Body:\n%s\n", string(data))
return
}
fmt.Fprintf(os.Stderr, "Body: %v\n", body)
}
}
33 changes: 33 additions & 0 deletions products/ddr/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package ddr

import (
"context"
"net/http"
"net/http/httptest"
"testing"
)

func TestClientInjectsAuthHeaders(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "test-token" {
t.Fatalf("Authorization = %q, want %q", got, "test-token")
}
if got := r.Header.Get("X-CS-Header-Company"); got != "company-1" {
t.Fatalf("X-CS-Header-Company = %q, want %q", got, "company-1")
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"msg":"ok","data":{"items":[]}}`))
}))
defer server.Close()

client := NewClient(&Config{
URL: server.URL,
APIKey: "test-token",
CompanyID: "company-1",
}, nil, false)

var result map[string]interface{}
if err := client.Do(context.Background(), http.MethodGet, "/health", nil, nil, nil, &result); err != nil {
t.Fatalf("Do() error = %v", err)
}
}
Loading
Loading