diff --git a/wire/response.go b/wire/response.go new file mode 100644 index 0000000..b40ebd4 --- /dev/null +++ b/wire/response.go @@ -0,0 +1,90 @@ +package wire + +import ( + "bytes" + "encoding/json" + "net/http" + "time" +) + +// WriteJSON encodes v as JSON with HTML escaping disabled and no trailing +// newline, matching the PHP json_encode body shape used by house handlers. +func WriteJSON(w http.ResponseWriter, status int, v any) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + WriteOpaque500(w) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(bytes.TrimSuffix(buf.Bytes(), []byte("\n"))) +} + +// WriteOpaque500 writes the house opaque 500 body. +func WriteOpaque500(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":true,"message":"Internal server error"}`)) +} + +// Time marshals as Carbon's +00:00 form (2006-01-02T15:04:05+00:00), never +// Go's default "Z". Unmarshal accepts both +00:00 and Z for read paths. +type Time struct{ time.Time } + +const ( + carbonLayout = "2006-01-02T15:04:05-07:00" + rfc3339Z = time.RFC3339 +) + +func (t Time) MarshalJSON() ([]byte, error) { + s := t.UTC().Format("2006-01-02T15:04:05") + "+00:00" + return []byte(`"` + s + `"`), nil +} + +func (t *Time) UnmarshalJSON(b []byte) error { + if t == nil { + return nil + } + if string(b) == "null" { + t.Time = time.Time{} + return nil + } + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + var err error + for _, layout := range []string{carbonLayout, rfc3339Z} { + var parsed time.Time + parsed, err = time.Parse(layout, s) + if err == nil { + t.Time = parsed + return nil + } + } + return err +} + +// TriBool keeps PHP's nullable-boolean tri-state: Valid=false marshals null. +type TriBool struct{ Valid, Value bool } + +func (b TriBool) MarshalJSON() ([]byte, error) { + if !b.Valid { + return []byte("null"), nil + } + if b.Value { + return []byte("true"), nil + } + return []byte("false"), nil +} + +// Slice returns s unchanged if non-nil, else a non-nil empty slice that +// marshals to []. +func Slice[T any](s []T) []T { + if s == nil { + return []T{} + } + return s +} diff --git a/wire/response_test.go b/wire/response_test.go new file mode 100644 index 0000000..857343b --- /dev/null +++ b/wire/response_test.go @@ -0,0 +1,113 @@ +package wire + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "regexp" + "testing" + "time" +) + +// carbonOffsetRe is tide/normalize.go's Carbon +00:00 form, copied so wire +// does not import tide. +var carbonOffsetRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$`) + +func TestTimeMarshalJSONCarbonOffset(t *testing.T) { + tm := Time{Time: time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)} + b, err := json.Marshal(tm) + if err != nil { + t.Fatal(err) + } + var s string + if err := json.Unmarshal(b, &s); err != nil { + t.Fatal(err) + } + if !carbonOffsetRe.MatchString(s) { + t.Fatalf("got %q, want carbon +00:00 form", s) + } + if string(b) != `"2024-01-02T03:04:05+00:00"` { + t.Fatalf("bytes = %s", b) + } +} + +func TestTimeUnmarshalJSONAcceptsOffsetAndZ(t *testing.T) { + var offset Time + if err := json.Unmarshal([]byte(`"2024-01-02T03:04:05+00:00"`), &offset); err != nil { + t.Fatal(err) + } + if !offset.Equal(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)) { + t.Fatalf("offset = %v", offset.Time) + } + var z Time + if err := json.Unmarshal([]byte(`"2024-01-02T03:04:05Z"`), &z); err != nil { + t.Fatal(err) + } + if !z.Equal(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)) { + t.Fatalf("z = %v", z.Time) + } +} + +func TestTriBoolMarshalJSON(t *testing.T) { + null, err := json.Marshal(TriBool{Valid: false}) + if err != nil { + t.Fatal(err) + } + if string(null) != "null" { + t.Fatalf("invalid = %s", null) + } + f, err := json.Marshal(TriBool{Valid: true, Value: false}) + if err != nil { + t.Fatal(err) + } + if string(f) != "false" { + t.Fatalf("false = %s", f) + } + tr, err := json.Marshal(TriBool{Valid: true, Value: true}) + if err != nil { + t.Fatal(err) + } + if string(tr) != "true" { + t.Fatalf("true = %s", tr) + } +} + +func TestSliceNilMarshalsEmptyArray(t *testing.T) { + s := Slice[int](nil) + if s == nil { + t.Fatal("Slice(nil) returned nil") + } + b, err := json.Marshal(s) + if err != nil { + t.Fatal(err) + } + if string(b) != "[]" { + t.Fatalf("got %s", b) + } +} + +func TestWriteJSONNoHTMLEscapeNoTrailingNewline(t *testing.T) { + rec := httptest.NewRecorder() + WriteJSON(rec, http.StatusOK, map[string]string{"a": "x