refactor(10.2-01): nest framework packages under modules
- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
This commit is contained in:
90
modules/wire/response.go
Normal file
90
modules/wire/response.go
Normal file
@@ -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
|
||||
}
|
||||
72
modules/wire/response_coverage_test.go
Normal file
72
modules/wire/response_coverage_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package wire
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Gap (e): Time.UnmarshalJSON round-trips both +00:00 and Z. The happy
|
||||
// path already lives in TestTimeUnmarshalJSONAcceptsOffsetAndZ; this
|
||||
// file covers null, invalid JSON, and a non-nil empty slice.
|
||||
|
||||
func TestTimeUnmarshalJSONNullAndInvalid(t *testing.T) {
|
||||
var tm Time
|
||||
if err := json.Unmarshal([]byte("null"), &tm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tm.IsZero() {
|
||||
t.Fatalf("null must decode to zero Time, got %v", tm.Time)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(`"not-a-time"`), &tm); err == nil {
|
||||
t.Fatal("invalid timestamp must fail")
|
||||
}
|
||||
if err := json.Unmarshal([]byte(`42`), &tm); err == nil {
|
||||
t.Fatal("non-string must fail")
|
||||
}
|
||||
var nilT *Time
|
||||
if err := nilT.UnmarshalJSON([]byte(`"2024-01-02T03:04:05Z"`)); err != nil {
|
||||
t.Fatalf("nil receiver: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeUnmarshalJSONOffsetAndZRoundTrip(t *testing.T) {
|
||||
want := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
for _, raw := range []string{`"2024-01-02T03:04:05+00:00"`, `"2024-01-02T03:04:05Z"`} {
|
||||
var tm Time
|
||||
if err := json.Unmarshal([]byte(raw), &tm); err != nil {
|
||||
t.Fatalf("%s: %v", raw, err)
|
||||
}
|
||||
if !tm.Equal(want) {
|
||||
t.Fatalf("%s = %v", raw, tm.Time)
|
||||
}
|
||||
b, err := json.Marshal(tm)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(b) != `"2024-01-02T03:04:05+00:00"` {
|
||||
t.Fatalf("marshal %s -> %s", raw, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteJSONEncodeErrorWritesOpaque500(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
WriteJSON(rec, http.StatusOK, make(chan int))
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
if rec.Body.String() != `{"error":true,"message":"Internal server error"}` {
|
||||
t.Fatalf("body = %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSliceNonNilUnchanged(t *testing.T) {
|
||||
in := []int{1, 2}
|
||||
got := Slice(in)
|
||||
if len(got) != 2 || got[0] != 1 || got[1] != 2 {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
113
modules/wire/response_test.go
Normal file
113
modules/wire/response_test.go
Normal file
@@ -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<y"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
got := rec.Body.String()
|
||||
if got != `{"a":"x<y"}` {
|
||||
t.Fatalf("body = %q", got)
|
||||
}
|
||||
if rec.Header().Get("Content-Type") != "application/json" {
|
||||
t.Fatalf("Content-Type = %q", rec.Header().Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteOpaque500(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
WriteOpaque500(rec)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
if rec.Body.String() != `{"error":true,"message":"Internal server error"}` {
|
||||
t.Fatalf("body = %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user