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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Git hooks (`.githooks/pre-commit`, `.githooks/pre-push`) that enforce formatting and
mirror CI checks locally; enable with `make hooks`.
- `Makefile` with `hooks`, `setup`, `lint`, `test`, and `build` targets.
- **v0.2 — Response insight (bundle A):** response status code + status text, request
duration, and body size shown next to the Response heading; status chip is colored by
HTTP class.
- **v0.2 — Body Pretty/Raw toggle** and **search-in-response** with match count, plus
**Copy** and **Save** buttons (the latter via a new `SaveTextFile` Go binding using a
native save dialog).
- **v0.2 — Request power (bundle B):** query-param builder synced two-way with the URL
field; **Auth** section (None / Bearer / Basic / API key with header-or-query target);
**Body** selector (None / JSON with a Format button / Form / Raw); per-request
**Settings** (timeout, follow-redirects, verify TLS); **Copy as cURL** action that
mirrors cURL import.

### Changed
- Backend: introduced `RequestSpec` and a new `Send(spec) RequestResult` bound method
that builds a per-request `http.Client` from settings (timeout, redirects, TLS verify).
`MakeRequest` is retained as a thin wrapper for backward compatibility.
- `RequestResult` now includes `Status`, `StatusText`, `DurationMs`, and `SizeBytes`.
- Non-JSON response bodies are returned verbatim instead of swallowed when JSON pretty-
printing fails.
- Request editor reorganized into tabbed sections (Headers / Params / Body / Auth /
Settings) within a single panel.
- cURL import now populates the header rows and request body of the active tab.
- The response view now follows the active request tab.
- The "add header" action moved next to the Request Headers title.

### Deferred (planned for v0.3)
- Multipart / file-upload body type.
- Iframe HTML preview tab for the response body.

### Fixed
- `//go:embed all:frontend/dist` caused a compile failure on clean checkouts because
`frontend/dist/` was gitignored; fixed by committing a `.gitkeep` and updating
Expand Down
169 changes: 138 additions & 31 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"io"
"net/http"
Expand Down Expand Up @@ -35,7 +36,7 @@ func (a *App) startup(ctx context.Context) {
a.ctx = ctx
}

func makeRequest(c *http.Client,
func doRequest(c *http.Client,
r *http.Request) ([]byte, *http.Response, error) {
resp, err := c.Do(r)
if err != nil {
Expand All @@ -62,6 +63,29 @@ func HeadersToStr(h *http.Header) string {
return result
}

// BodySpec describes the request body type and content.
type BodySpec struct {
Type string `json:"type"` // "none" | "json" | "form" | "raw"
Raw string `json:"raw"` // raw/json text or pre-encoded form body
}

// RequestSettings contains per-request client settings.
type RequestSettings struct {
TimeoutMs int `json:"timeoutMs"` // 0 = use default (50s)
FollowRedirects bool `json:"followRedirects"` // true = follow
VerifyTLS bool `json:"verifyTLS"` // true = verify
}

// RequestSpec is the structured request used by Send().
type RequestSpec struct {
Method string `json:"method"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
Body BodySpec `json:"body"`
Settings RequestSettings `json:"settings"`
}

// RequestResult is the response shape returned to the UI.
type RequestResult struct {
Method string `json:"Method"`
URL string `json:"URL"`
Expand All @@ -70,6 +94,97 @@ type RequestResult struct {
Body string `json:"Body"`
HeadersStr string `json:"HeadersStr"`
Error string `json:"Error"`
Status int `json:"Status"`
StatusText string `json:"StatusText"`
DurationMs int64 `json:"DurationMs"`
SizeBytes int `json:"SizeBytes"`
}

// buildClient creates an http.Client configured from RequestSettings.
func buildClient(s RequestSettings) *http.Client {
timeout := 50 * time.Second
if s.TimeoutMs > 0 {
timeout = time.Duration(s.TimeoutMs) * time.Millisecond
}

tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: !s.VerifyTLS}, //nolint:gosec
}

client := &http.Client{
Timeout: timeout,
Transport: tr,
}

if !s.FollowRedirects {
client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}
}

return client
}

// Send builds a per-request http.Client from Settings and executes the request.
func (a *App) Send(spec RequestSpec) RequestResult {
result := RequestResult{
URL: spec.URL,
Method: spec.Method,
}

var bodyReader io.Reader
if spec.Body.Type != "none" && spec.Body.Raw != "" {
bodyReader = strings.NewReader(spec.Body.Raw)
result.RequestBody = spec.Body.Raw
}

r, err := http.NewRequest(spec.Method, spec.URL, bodyReader)
if err != nil {
result.Error = err.Error()
return result
}

for key, value := range spec.Headers {
r.Header.Add(key, value)
}

result.ReqHeaders = HeadersToStr(&r.Header)

client := buildClient(spec.Settings)

start := time.Now()
res, httpResp, err := doRequest(client, r)
result.DurationMs = time.Since(start).Milliseconds()

if err != nil {
result.Error = err.Error()
// still capture status if response was partially received
if httpResp != nil {
result.Status = httpResp.StatusCode
result.StatusText = httpResp.Status
}
return result
}

result.Status = httpResp.StatusCode
result.StatusText = httpResp.Status
result.HeadersStr = HeadersToStr(&httpResp.Header)
result.SizeBytes = len(res)

// Pretty-print JSON bodies; on failure, return raw body without error
b := bytes.NewBuffer(make([]byte, 0, len(res)))
if jsonErr := json.Indent(b, res, "", " "); jsonErr == nil {
result.Body = b.String()
} else {
result.Body = string(res)
}

return result
}

// SaveTextFile opens a native save dialog and writes text content to the chosen file.
func (a *App) SaveTextFile(filename, contents string) error {
return saveTextFile(a.ctx, filename, contents)
}

func (a *App) RunCurl(curl string) RequestResult {
Expand All @@ -88,50 +203,42 @@ func (a *App) RunCurl(curl string) RequestResult {
return res
}

// Header is a single key/value header pair (kept for Export compatibility).
type Header struct {
Key string
Value string
}

// Greet returns a greeting for the given name
// MakeRequest is kept for backward compatibility; it delegates to Send.
func (a *App) MakeRequest(
urlIn string,
method string,
body string,
headers Headers,
) RequestResult {
result := RequestResult{
URL: urlIn,
Method: method,
RequestBody: body,
}
rbody := bytes.NewBuffer([]byte(body))
r, err := http.NewRequest(method, urlIn, rbody)
if err != nil {
result.Error = err.Error()
return result
hdrs := make(map[string]string, len(headers))
for k, v := range headers {
hdrs[k] = v
}

for key, value := range headers {
r.Header.Add(key, value)
}

res, httpResp, err := makeRequest(a.client, r)
if err != nil {
result.Error = err.Error()
return result
}

result.HeadersStr = HeadersToStr(&httpResp.Header)
b := bytes.NewBuffer(make([]byte, 0, len(res)))
err = json.Indent(b, res, "\n", " ")
if err != nil {
return RequestResult{
Body: string(res),
Error: err.Error(),
}
spec := RequestSpec{
Method: method,
URL: urlIn,
Headers: hdrs,
Body: BodySpec{
Type: "raw",
Raw: body,
},
Settings: RequestSettings{
FollowRedirects: true,
VerifyTLS: true,
},
}

result.Body = b.String()
result := a.Send(spec)
// preserve old fields that Send populates differently
result.Method = method
result.URL = urlIn
result.RequestBody = body
return result
}
36 changes: 36 additions & 0 deletions export.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"context"
"encoding/json"
"log"
"os"
Expand Down Expand Up @@ -56,3 +57,38 @@ func (a *App) Export(req Request, reqHeaders [][]Header, reqBodies []string, r R

return nil
}

// saveTextFile opens a native save dialog and writes plain text to the chosen path.
func saveTextFile(ctx context.Context, defaultName, contents string) error {
if defaultName == "" {
defaultName = "response.txt"
}

fp, err := runtime.SaveFileDialog(ctx, runtime.SaveDialogOptions{
DefaultFilename: defaultName,
Title: "Save response body",
})
if err != nil {
log.Println(err)
return err
}

if fp == "" {
// user cancelled the dialog
return nil
}

f, err := os.Create(fp)
if err != nil {
log.Println(err)
return err
}
defer f.Close()

if _, err := f.WriteString(contents); err != nil {
log.Println(err)
return err
}

return nil
}
Loading
Loading