summaryrefslogtreecommitdiff
path: root/internal/store/har.go
diff options
context:
space:
mode:
authorspiperac <[email protected]>2026-06-15 06:42:13 +0200
committerspiperac <[email protected]>2026-06-15 06:42:13 +0200
commit0a477fd288d064d265395c88dbd0f261b723804e (patch)
tree9b129f8f41c907285b89ed8385679934bb59c14f /internal/store/har.go
Initial release
Diffstat (limited to 'internal/store/har.go')
-rw-r--r--internal/store/har.go239
1 files changed, 239 insertions, 0 deletions
diff --git a/internal/store/har.go b/internal/store/har.go
new file mode 100644
index 0000000..047300a
--- /dev/null
+++ b/internal/store/har.go
@@ -0,0 +1,239 @@
+package store
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+// HAR 1.2 — https://w3c.github.io/web-performance/specs/HAR/Overview.html
+
+type HAR struct {
+ Log HARLog `json:"log"`
+}
+
+type HARLog struct {
+ Version string `json:"version"`
+ Creator HARCreator `json:"creator"`
+ Entries []HAREntry `json:"entries"`
+}
+
+type HARCreator struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+}
+
+type HAREntry struct {
+ StartedDateTime string `json:"startedDateTime"`
+ Time int64 `json:"time"`
+ Request HARRequest `json:"request"`
+ Response HARResponse `json:"response"`
+ Timings HARTimings `json:"timings"`
+}
+
+type HARRequest struct {
+ Method string `json:"method"`
+ URL string `json:"url"`
+ HTTPVersion string `json:"httpVersion"`
+ Headers []HARNameValue `json:"headers"`
+ QueryString []HARNameValue `json:"queryString"`
+ PostData *HARPostData `json:"postData,omitempty"`
+ HeadersSize int `json:"headersSize"`
+ BodySize int `json:"bodySize"`
+}
+
+type HARResponse struct {
+ Status int `json:"status"`
+ StatusText string `json:"statusText"`
+ HTTPVersion string `json:"httpVersion"`
+ Headers []HARNameValue `json:"headers"`
+ Content HARContent `json:"content"`
+ RedirectURL string `json:"redirectURL"`
+ HeadersSize int `json:"headersSize"`
+ BodySize int `json:"bodySize"`
+}
+
+type HARNameValue struct {
+ Name string `json:"name"`
+ Value string `json:"value"`
+}
+
+type HARPostData struct {
+ MimeType string `json:"mimeType"`
+ Text string `json:"text"`
+}
+
+type HARContent struct {
+ Size int `json:"size"`
+ MimeType string `json:"mimeType"`
+ Text string `json:"text,omitempty"`
+}
+
+type HARTimings struct {
+ Send int64 `json:"send"`
+ Wait int64 `json:"wait"`
+ Receive int64 `json:"receive"`
+}
+
+// ExportHAR serialises the given transactions into a HAR 1.2 JSON byte slice.
+// Pass only the transactions you want exported — scope and out-of-scope
+// filtering is the caller's responsibility.
+func ExportHAR(txs []Transaction) ([]byte, error) {
+ har := HAR{
+ Log: HARLog{
+ Version: "1.2",
+ Creator: HARCreator{
+ Name: "Shiv",
+ Version: "1.0",
+ },
+ Entries: make([]HAREntry, 0, len(txs)),
+ },
+ }
+
+ for _, tx := range txs {
+ entry := HAREntry{
+ StartedDateTime: tx.Timestamp.UTC().Format(time.RFC3339),
+ Time: tx.DurationMs,
+ Request: buildHARRequest(tx),
+ Response: buildHARResponse(tx),
+ Timings: HARTimings{
+ Send: 0,
+ Wait: tx.DurationMs,
+ Receive: 0,
+ },
+ }
+ har.Log.Entries = append(har.Log.Entries, entry)
+ }
+
+ data, err := json.MarshalIndent(har, "", " ")
+ if err != nil {
+ return nil, fmt.Errorf("har: marshal: %w", err)
+ }
+ return data, nil
+}
+
+func stripDefaultPort(rawURL string) string {
+ for _, prefix := range []string{"https://", "http://"} {
+ if !strings.HasPrefix(rawURL, prefix) {
+ continue
+ }
+ rest := rawURL[len(prefix):]
+ slash := strings.Index(rest, "/")
+ if slash < 0 {
+ slash = len(rest)
+ }
+ host := rest[:slash]
+ path := rest[slash:]
+ h, port, err := net.SplitHostPort(host)
+ if err != nil {
+ return rawURL
+ }
+ if (prefix == "https://" && port == "443") || (prefix == "http://" && port == "80") {
+ return prefix + h + path
+ }
+ return rawURL
+ }
+ return rawURL
+}
+
+func buildHARRequest(tx Transaction) HARRequest {
+ headers := make([]HARNameValue, 0, len(tx.ReqHeaders))
+ for k, vals := range tx.ReqHeaders {
+ for _, v := range vals {
+ headers = append(headers, HARNameValue{Name: k, Value: v})
+ }
+ }
+
+ httpVersion := tx.Proto
+ if httpVersion == "" {
+ httpVersion = "HTTP/1.1"
+ }
+
+ req := HARRequest{
+ Method: tx.Method,
+ URL: stripDefaultPort(tx.URL),
+ HTTPVersion: httpVersion,
+ Headers: headers,
+ QueryString: extractQueryString(tx.URL),
+ HeadersSize: -1,
+ BodySize: len(tx.ReqBody),
+ }
+
+ if len(tx.ReqBody) > 0 {
+ mimeType := tx.ReqHeaders.Get("Content-Type")
+ if mimeType == "" {
+ mimeType = "application/octet-stream"
+ }
+ req.PostData = &HARPostData{
+ MimeType: mimeType,
+ Text: string(tx.ReqBody),
+ }
+ }
+
+ return req
+}
+
+func buildHARResponse(tx Transaction) HARResponse {
+ headers := make([]HARNameValue, 0, len(tx.RespHeaders))
+ for k, vals := range tx.RespHeaders {
+ for _, v := range vals {
+ headers = append(headers, HARNameValue{Name: k, Value: v})
+ }
+ }
+
+ httpVersion := tx.Proto
+ if httpVersion == "" {
+ httpVersion = "HTTP/1.1"
+ }
+
+ mimeType := tx.RespHeaders.Get("Content-Type")
+ if mimeType == "" {
+ mimeType = "application/octet-stream"
+ }
+
+ redirectURL := tx.RespHeaders.Get("Location")
+
+ return HARResponse{
+ Status: tx.StatusCode,
+ StatusText: statusText(tx.StatusCode),
+ HTTPVersion: httpVersion,
+ Headers: headers,
+ Content: HARContent{
+ Size: len(tx.RespBody),
+ MimeType: mimeType,
+ Text: string(tx.RespBody),
+ },
+ RedirectURL: redirectURL,
+ HeadersSize: -1,
+ BodySize: len(tx.RespBody),
+ }
+}
+
+func extractQueryString(rawURL string) []HARNameValue {
+ idx := strings.Index(rawURL, "?")
+ if idx < 0 {
+ return []HARNameValue{}
+ }
+ parsed, err := url.ParseQuery(rawURL[idx+1:])
+ if err != nil || len(parsed) == 0 {
+ return []HARNameValue{}
+ }
+ pairs := make([]HARNameValue, 0, len(parsed))
+ for k, vals := range parsed {
+ for _, v := range vals {
+ pairs = append(pairs, HARNameValue{Name: k, Value: v})
+ }
+ }
+ return pairs
+}
+
+func statusText(code int) string {
+ if t := http.StatusText(code); t != "" {
+ return t
+ }
+ return fmt.Sprintf("%d", code)
+}