diff --git a/api_request_builder.go b/api_request_builder.go new file mode 100644 index 0000000..8abceab --- /dev/null +++ b/api_request_builder.go @@ -0,0 +1,124 @@ +package mujica + +import ( + "fmt" + "log/slog" + "net/url" + "slices" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// ApiRequest is an HTTP request being built; the caller sends it. +type ApiRequest struct { + // Method is GET when empty. + Method string + URL string + Params []KeyValue + Headers []KeyValue + Body string +} + +// ApiRequestBuilderOptions configure an ApiRequestBuilder. +type ApiRequestBuilderOptions struct { + // Sending shows the request in flight and blocks another send. + Sending bool + Disabled bool +} + +// ApiRequestResult reports this frame's events. +type ApiRequestResult struct { + Element *ui.Element + // Send asks the caller to send the request. + Send bool + Changed bool +} + +type apiRequestState struct{ tab int } + +var apiMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"} + +// ApiRequestBuilder edits method, URL, query parameters, headers and body, and reports a send. +func ApiRequestBuilder(c *ui.Context, req *ApiRequest, opts ApiRequestBuilderOptions) ApiRequestResult { + if req == nil { + panic("mujica: ApiRequestBuilder needs a request") + } + if req.Method == "" { + req.Method = "GET" + } + if !slices.Contains(apiMethods, req.Method) { + panic("mujica: ApiRequestBuilder method " + req.Method + " is not offered") + } + u := Density(c).Unit() + var r ApiRequestResult + r.Element = ui.Column(c).Shrink(0).Gap(u * 3).Disabled(opts.Disabled) + st := ui.Local(r.Element, "mujica.apirequest", func() apiRequestState { return apiRequestState{} }) + locked := r.Element.IsDisabled() + full, urlErr := apiRequestURL(*req) + r.Element.Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Start).Children(func() { + methods := make([]SelectOption[string], len(apiMethods)) + for i, m := range apiMethods { + methods[i] = SelectOption[string]{Value: m, Label: m} + } + r.Changed = Select(c, &req.Method, methods, SelectOptions{Label: msg(c, "api_request_builder.method"), Disabled: locked}).Changed() + shownErr := "" + if req.URL != "" && urlErr != nil { + shownErr = urlErr.Error() + } + in := TextInput(c, &req.URL, TextInputOptions{Placeholder: "https://", Label: msg(c, "api_request_builder.url"), Disabled: locked, Error: shownErr}) + in.Element.Grow(1) + r.Changed = r.Changed || in.Changed() + ready := urlErr == nil && !opts.Sending && !locked + send := Button(c, msg(c, "api_request_builder.send"), ButtonOptions{Icon: icons.Must("upload"), Loading: opts.Sending, Disabled: locked || urlErr != nil}) + r.Send = ready && (send.Clicked() || in.Submitted()) + }) + if urlErr == nil { + codeMono(c, req.Method+" "+full).TextColor(Tokens(c).TextMuted) + } + tabs := []Tab{ + {Label: fmt.Sprintf(msg(c, "api_request_builder.params"), len(keyValueEnabled(req.Params))), Panel: func() { + r.Changed = KeyValueInput(c, &req.Params, KeyValueInputOptions{Label: msg(c, "api_request_builder.params_label"), Disabled: locked}).Changed() || r.Changed + }}, + {Label: fmt.Sprintf(msg(c, "api_request_builder.headers"), len(keyValueEnabled(req.Headers))), Panel: func() { + r.Changed = KeyValueInput(c, &req.Headers, KeyValueInputOptions{Label: msg(c, "api_request_builder.headers_label"), Disabled: locked}).Changed() || r.Changed + }}, + {Label: msg(c, "api_request_builder.body"), Panel: func() { + if req.Method == "GET" || req.Method == "HEAD" { + agentCaption(c, msg(c, "api_request_builder.no_body")) + } + body := TextArea(c, &req.Body, TextAreaOptions{Label: msg(c, "api_request_builder.body"), MinHeight: 120, MaxHeight: 320, Disabled: locked}) + body.Input.Font(theme.MonoFont) + r.Changed = r.Changed || body.Changed() + }}, + } + Tabs(c, &st.tab, tabs, TabsOptions{Label: msg(c, "api_request_builder.parts")}) + }) + if r.Send { + slog.Debug("mujica: api_request_builder send", "method", req.Method) // never the URL: it may hold a token + } + return r +} + +// apiRequestURL joins the URL with its enabled query parameters, or says why it is invalid. +func apiRequestURL(req ApiRequest) (string, error) { + if req.URL == "" { + return "", fmt.Errorf("empty URL") + } + u, err := url.Parse(req.URL) + if err != nil { + return "", err + } + if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return "", fmt.Errorf("the URL needs http:// or https:// and a host") + } + q := u.Query() + for _, kv := range keyValueEnabled(req.Params) { + q.Add(kv.Key, kv.Value) + } + u.RawQuery = q.Encode() + return u.String(), nil +} diff --git a/api_request_builder_test.go b/api_request_builder_test.go new file mode 100644 index 0000000..151a7c5 --- /dev/null +++ b/api_request_builder_test.go @@ -0,0 +1,93 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func apiTester(req *ApiRequest, opts *ApiRequestBuilderOptions, sends *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(8).Children(func() { + if ApiRequestBuilder(c, req, *opts).Send { + *sends++ + } + }) + }, 760, 520) +} + +func TestApiRequestBuilderBuildAndSend(t *testing.T) { + var req ApiRequest + opts := ApiRequestBuilderOptions{} + sends := 0 + tt := apiTester(&req, &opts, &sends) + if req.Method != "GET" { + t.Fatalf("default method %q", req.Method) + } + _ = tt.Click("Send") // disabled without a URL + _ = tt.Click("Request URL") + tt.Type("ftp://court") + if !tt.HasText("the URL needs http:// or https:// and a host") || sends != 0 { + t.Fatalf("invalid URL: %q", tt.Texts()) + } + req.URL = "" + tt.Frame() + _ = tt.Click("Request URL") + tt.Type("https://court.example/guests") + _ = tt.Click("Add row") + _ = tt.Click("Row 1 Key") + tt.Type("page") + tt.Key(0, ui.KeyTab) + tt.Type("2") + if !tt.HasText("GET https://court.example/guests?page=2") || !tt.HasText("Params (1)") { + t.Fatalf("preview: %q", tt.Texts()) + } + _ = tt.Click("Request URL") + tt.Key(0, ui.KeyEnter) + _ = tt.Click("Send") + if sends != 2 { + t.Fatalf("sends %d", sends) + } + _ = tt.Click("Method") + _ = tt.Click("POST") + _ = tt.Click("Body") + if req.Method != "POST" || tt.HasText("GET and HEAD requests usually carry no body.") { + t.Fatalf("method %q", req.Method) + } +} + +func TestApiRequestBuilderSendingAndDisabled(t *testing.T) { + req := ApiRequest{URL: "https://court.example"} + opts := ApiRequestBuilderOptions{Sending: true} + sends := 0 + tt := apiTester(&req, &opts, &sends) + _ = tt.Click("Request URL") + tt.Key(0, ui.KeyEnter) + if sends != 0 { + t.Fatal("sent while sending") + } + opts = ApiRequestBuilderOptions{Disabled: true} + tt.Frame() + _ = tt.Click("Send") + _ = tt.Click("Request URL") + tt.Type("x") + if sends != 0 || req.URL != "https://court.example" { + t.Fatalf("disabled: %q sends %d", req.URL, sends) + } + defer func() { + if recover() == nil { + t.Fatal("unknown method did not panic") + } + }() + apiTester(&ApiRequest{Method: "BREW"}, &ApiRequestBuilderOptions{}, &sends) +} + +func TestApiRequestBuilderSnapshot(t *testing.T) { + snapshot(t, "api_request_builder", 720, 330, func(c *ui.Context) { + req := ApiRequest{Method: "POST", URL: "https://court.example/guests", + Params: []KeyValue{{Key: "notify", Value: "true", Enabled: true}}, + Headers: []KeyValue{{Key: "Accept", Value: "application/json", Enabled: true}}} + ui.Column(c).Padding(16).Children(func() { ApiRequestBuilder(c, &req, ApiRequestBuilderOptions{}) }) + }) +} diff --git a/messages_devtools.go b/messages_devtools.go index 57d55fd..1b8a9e1 100644 --- a/messages_devtools.go +++ b/messages_devtools.go @@ -95,6 +95,17 @@ func init() { "schema_tree.column": {"Column", "列"}, "schema_tree.index": {"Index", "索引"}, + "api_request_builder.method": {"Method", "方法"}, + "api_request_builder.url": {"Request URL", "请求地址"}, + "api_request_builder.send": {"Send", "发送"}, + "api_request_builder.params": {"Params (%d)", "参数(%d)"}, + "api_request_builder.params_label": {"Query parameters", "查询参数"}, + "api_request_builder.headers": {"Headers (%d)", "请求头(%d)"}, + "api_request_builder.headers_label": {"Request headers", "请求头"}, + "api_request_builder.body": {"Body", "正文"}, + "api_request_builder.no_body": {"GET and HEAD requests usually carry no body.", "GET 与 HEAD 请求通常不带正文。"}, + "api_request_builder.parts": {"Request parts", "请求组成"}, + "alert_list.empty": {"No alerts", "没有告警"}, }) } diff --git a/testdata/snapshots/darwin/api_request_builder-dark.png b/testdata/snapshots/darwin/api_request_builder-dark.png new file mode 100644 index 0000000..9639561 Binary files /dev/null and b/testdata/snapshots/darwin/api_request_builder-dark.png differ diff --git a/testdata/snapshots/darwin/api_request_builder-light.png b/testdata/snapshots/darwin/api_request_builder-light.png new file mode 100644 index 0000000..89bc6c3 Binary files /dev/null and b/testdata/snapshots/darwin/api_request_builder-light.png differ