chore(repo): reinitialize repository

This commit is contained in:
2026-03-18 11:29:54 +08:00
commit 24871e213a
288 changed files with 44369 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
package clientcmd
import (
"context"
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"inbox/internal/client"
)
const DefaultAPIURL = "http://127.0.0.1:3000"
type APIClient interface {
Do(ctx context.Context, method, path string, headers http.Header, body []byte) (client.Response, error)
}
type APIDeps struct {
Stdout io.Writer
Stdin io.Reader
NewClient func(baseURL string, httpClient *http.Client) APIClient
}
type headersFlag struct {
values []string
}
func (h *headersFlag) String() string {
return strings.Join(h.values, ",")
}
func (h *headersFlag) Set(value string) error {
value = strings.TrimSpace(value)
if value == "" {
return fmt.Errorf("header cannot be empty")
}
h.values = append(h.values, value)
return nil
}
func RunAPI(args []string, deps APIDeps) error {
flagSet := flag.NewFlagSet("api", flag.ContinueOnError)
flagSet.SetOutput(io.Discard)
addr := flagSet.String("addr", strings.TrimSpace(os.Getenv("INBOX_API_URL")), "Inbox API base URL")
data := flagSet.String("data", "", "Inline request body")
file := flagSet.String("file", "", "Request body file path, or - for stdin")
timeout := flagSet.Duration("timeout", 30*time.Second, "HTTP request timeout")
var headerValues headersFlag
flagSet.Var(&headerValues, "header", "HTTP header in 'Key: Value' format; repeatable")
if err := flagSet.Parse(normalizeAPIArgs(args)); err != nil {
return err
}
rest := flagSet.Args()
if len(rest) < 2 {
return fmt.Errorf("usage: inbox api [flags] METHOD PATH")
}
method := strings.ToUpper(strings.TrimSpace(rest[0]))
path := strings.TrimSpace(rest[1])
if method == "" || path == "" {
return fmt.Errorf("usage: inbox api [flags] METHOD PATH")
}
if *addr == "" {
*addr = DefaultAPIURL
}
body, err := loadBody(*data, *file, deps.Stdin)
if err != nil {
return err
}
headers, err := buildHeaders(headerValues.values, body)
if err != nil {
return err
}
newClient := deps.NewClient
if newClient == nil {
newClient = func(baseURL string, httpClient *http.Client) APIClient {
return client.New(baseURL, httpClient)
}
}
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
resp, err := newClient(*addr, &http.Client{Timeout: *timeout}).Do(ctx, method, path, headers, body)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
payload := strings.TrimSpace(string(resp.Body))
if payload == "" {
return fmt.Errorf("api %s %s returned status %d", method, path, resp.StatusCode)
}
return fmt.Errorf("api %s %s returned status %d: %s", method, path, resp.StatusCode, payload)
}
stdout := deps.Stdout
if stdout == nil {
stdout = os.Stdout
}
if len(resp.Body) == 0 {
return nil
}
if _, err := stdout.Write(resp.Body); err != nil {
return fmt.Errorf("write response: %w", err)
}
if len(resp.Body) > 0 && resp.Body[len(resp.Body)-1] != '\n' {
if _, err := io.WriteString(stdout, "\n"); err != nil {
return fmt.Errorf("write trailing newline: %w", err)
}
}
return nil
}
func loadBody(data, file string, stdin io.Reader) ([]byte, error) {
if data != "" && file != "" {
return nil, fmt.Errorf("--data and --file cannot be used together")
}
switch {
case data != "":
return []byte(data), nil
case file == "":
return nil, nil
case file == "-":
if stdin == nil {
stdin = os.Stdin
}
body, err := io.ReadAll(stdin)
if err != nil {
return nil, fmt.Errorf("read stdin: %w", err)
}
return body, nil
default:
body, err := os.ReadFile(file)
if err != nil {
return nil, fmt.Errorf("read request file: %w", err)
}
return body, nil
}
}
func buildHeaders(items []string, body []byte) (http.Header, error) {
headers := make(http.Header)
for _, item := range items {
key, value, ok := strings.Cut(item, ":")
if !ok {
return nil, fmt.Errorf("invalid header %q", item)
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
if key == "" {
return nil, fmt.Errorf("invalid header %q", item)
}
headers.Add(key, value)
}
if len(body) > 0 && headers.Get("Content-Type") == "" {
headers.Set("Content-Type", "application/json")
}
return headers, nil
}
func normalizeAPIArgs(args []string) []string {
if len(args) == 0 {
return nil
}
flags := make([]string, 0, len(args))
positionals := make([]string, 0, len(args))
for i := 0; i < len(args); i++ {
arg := args[i]
if !strings.HasPrefix(arg, "-") {
positionals = append(positionals, arg)
continue
}
flags = append(flags, arg)
if strings.Contains(arg, "=") {
continue
}
if takesFlagValue(arg) && i+1 < len(args) {
flags = append(flags, args[i+1])
i++
}
}
return append(flags, positionals...)
}
func takesFlagValue(flagName string) bool {
switch flagName {
case "--addr", "--data", "--file", "--timeout", "--header":
return true
default:
return false
}
}
+109
View File
@@ -0,0 +1,109 @@
package clientcmd
import (
"bytes"
"context"
"net/http"
"strings"
"testing"
"inbox/internal/client"
)
type stubClient struct {
response client.Response
err error
method string
path string
headers http.Header
body []byte
}
func (s *stubClient) Do(_ context.Context, method, path string, headers http.Header, body []byte) (client.Response, error) {
s.method = method
s.path = path
s.headers = headers.Clone()
s.body = append([]byte(nil), body...)
return s.response, s.err
}
func TestRunAPIWritesResponseBody(t *testing.T) {
var stdout bytes.Buffer
stub := &stubClient{
response: client.Response{
StatusCode: http.StatusOK,
Body: []byte("{\"ok\":true}\n"),
},
}
err := RunAPI([]string{"POST", "/api/v2/topics", "--data", `{"title":"Alpha"}`}, APIDeps{
Stdout: &stdout,
NewClient: func(string, *http.Client) APIClient {
return stub
},
})
if err != nil {
t.Fatalf("RunAPI() error = %v", err)
}
if stub.method != http.MethodPost || stub.path != "/api/v2/topics" {
t.Fatalf("unexpected request: method=%s path=%s", stub.method, stub.path)
}
if got := strings.TrimSpace(stdout.String()); got != `{"ok":true}` {
t.Fatalf("stdout = %q", got)
}
if got := string(stub.body); got != `{"title":"Alpha"}` {
t.Fatalf("body = %q", got)
}
if got := stub.headers.Get("Content-Type"); got != "application/json" {
t.Fatalf("content type = %q", got)
}
}
func TestRunAPIReadsBodyFromStdin(t *testing.T) {
var stdout bytes.Buffer
stub := &stubClient{
response: client.Response{
StatusCode: http.StatusCreated,
Body: []byte("{}"),
},
}
err := RunAPI([]string{"PATCH", "/api/v2/requirements/req_1", "--file", "-"}, APIDeps{
Stdout: &stdout,
Stdin: strings.NewReader(`{"status":"completed"}`),
NewClient: func(string, *http.Client) APIClient {
return stub
},
})
if err != nil {
t.Fatalf("RunAPI() error = %v", err)
}
if got := string(stub.body); got != `{"status":"completed"}` {
t.Fatalf("stdin body = %q", got)
}
}
func TestRunAPIReturnsStatusError(t *testing.T) {
stub := &stubClient{
response: client.Response{
StatusCode: http.StatusBadRequest,
Body: []byte(`{"error":"bad request"}`),
},
}
err := RunAPI([]string{"GET", "/api/v2/topics"}, APIDeps{
NewClient: func(string, *http.Client) APIClient {
return stub
},
})
if err == nil || !strings.Contains(err.Error(), `status 400`) {
t.Fatalf("RunAPI() error = %v", err)
}
}
func TestRunAPIRejectsMissingArguments(t *testing.T) {
err := RunAPI([]string{"GET"}, APIDeps{})
if err == nil || !strings.Contains(err.Error(), "usage: inbox api") {
t.Fatalf("RunAPI() error = %v", err)
}
}