Skip to content

Commit 5f4577a

Browse files
committed
options: Allow setting time formatter as an option
1 parent 0ef4469 commit 5f4577a

4 files changed

Lines changed: 186 additions & 0 deletions

File tree

options.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,20 @@ func SkipUnknownFields(skip bool) Option {
3636
}
3737
}
3838
}
39+
40+
// TimeFormat option allows customizing how time.Time values are encoded to and decoded
41+
// from the XML-RPC <dateTime.iso8601> type. When unset, values are encoded and decoded
42+
// using time.RFC3339. See LayoutTimeFormatter for layout-based customization.
43+
//
44+
// This is only effective if using standard client, which in turn uses StdEncoder and StdDecoder.
45+
func TimeFormat(formatter TimeFormatter) Option {
46+
return func(client *Client) {
47+
if v, ok := client.codec.encoder.(*StdEncoder); ok {
48+
v.timeFormatter = formatter
49+
}
50+
51+
if v, ok := client.codec.decoder.(*StdDecoder); ok {
52+
v.timeFormatter = formatter
53+
}
54+
}
55+
}

options_test.go

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ package xmlrpc
22

33
import (
44
"bytes"
5+
"encoding/xml"
56
"fmt"
67
"io"
78
"net/http"
89
"net/http/httptest"
910
"testing"
11+
"time"
1012

1113
"github.com/stretchr/testify/require"
1214
)
@@ -254,3 +256,154 @@ func TestClient_Option_SkipUnknownFields(t *testing.T) {
254256
})
255257
}
256258
}
259+
260+
// recordingTimeFormatter is a third-party TimeFormatter, proving a single instance is
261+
// used for both directions and that its output is escaped on the way out.
262+
type recordingTimeFormatter struct {
263+
formatted int
264+
parsed int
265+
}
266+
267+
func (f *recordingTimeFormatter) FormatTime(_ time.Time) string {
268+
f.formatted++
269+
270+
return "encoded&value"
271+
}
272+
273+
func (f *recordingTimeFormatter) ParseTime(_ string) (time.Time, error) {
274+
f.parsed++
275+
276+
return time.Date(2001, 2, 3, 4, 5, 6, 0, time.UTC), nil
277+
}
278+
279+
func TestClient_Option_TimeFormat_CustomFormatter(t *testing.T) {
280+
formatter := &recordingTimeFormatter{}
281+
282+
var body string
283+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
284+
b, err := io.ReadAll(r.Body)
285+
require.NoError(t, err)
286+
body = string(b)
287+
288+
_, _ = fmt.Fprintln(w, string(loadTestFile(t, "response_datetime_compact.xml")))
289+
}))
290+
defer ts.Close()
291+
292+
c, err := NewClient(ts.URL, TimeFormat(formatter))
293+
require.NoError(t, err)
294+
295+
resp := &struct{ When time.Time }{}
296+
require.NoError(t, c.Call("test.Method", &struct{ When time.Time }{When: time.Now()}, resp))
297+
298+
require.Equal(t, 1, formatter.formatted, "the same instance must encode")
299+
require.Equal(t, 1, formatter.parsed, "the same instance must decode")
300+
require.Equal(t, 2001, resp.When.Year())
301+
302+
// formatter output must be escaped, keeping the request well-formed
303+
require.Contains(t, body, "<dateTime.iso8601>encoded&amp;value</dateTime.iso8601>")
304+
require.NoError(t, xml.Unmarshal([]byte(body), new(struct{})), "request body must be well-formed XML")
305+
}
306+
307+
func TestClient_Option_TimeFormat_TypedNilFormatter(t *testing.T) {
308+
// A typed nil is non-nil as an interface - it must not panic
309+
var formatter *LayoutTimeFormatter
310+
311+
serverCalled := false
312+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
313+
body, err := io.ReadAll(r.Body)
314+
require.NoError(t, err)
315+
require.Contains(t, string(body), "<dateTime.iso8601>2019-10-11T13:40:30Z</dateTime.iso8601>")
316+
317+
serverCalled = true
318+
_, _ = fmt.Fprintln(w, string(loadTestFile(t, "response_datetime_rfc3339.xml")))
319+
}))
320+
defer ts.Close()
321+
322+
c, err := NewClient(ts.URL, TimeFormat(formatter))
323+
require.NoError(t, err)
324+
325+
args := &struct{ When time.Time }{When: time.Date(2019, 10, 11, 13, 40, 30, 0, time.UTC)}
326+
resp := &struct{ When time.Time }{}
327+
require.NoError(t, c.Call("test.Method", args, resp))
328+
329+
// decode must go through the same fallback
330+
require.Equal(t, "2019-10-11T13:40:30Z", resp.When.Format(time.RFC3339))
331+
require.True(t, serverCalled, "server must be called")
332+
}
333+
334+
func TestClient_Option_TimeFormat(t *testing.T) {
335+
input := time.Date(2019, 10, 11, 13, 40, 30, 0, time.UTC)
336+
337+
tests := []struct {
338+
name string
339+
opts []Option
340+
expectEncode string
341+
expectDecode string
342+
expectErr bool
343+
}{
344+
{
345+
name: "default formatter",
346+
expectEncode: "<dateTime.iso8601>2019-10-11T13:40:30Z</dateTime.iso8601>",
347+
// Server responds with the compact form, which RFC3339 cannot parse
348+
expectErr: true,
349+
},
350+
{
351+
name: "nil formatter falls back to default",
352+
opts: []Option{
353+
TimeFormat(nil),
354+
},
355+
expectEncode: "<dateTime.iso8601>2019-10-11T13:40:30Z</dateTime.iso8601>",
356+
expectErr: true,
357+
},
358+
{
359+
name: "compact formatter",
360+
opts: []Option{
361+
TimeFormat(&LayoutTimeFormatter{FormatLayout: LayoutISO8601Compact}),
362+
},
363+
expectEncode: "<dateTime.iso8601>20191011T13:40:30</dateTime.iso8601>",
364+
expectDecode: "2019-10-11T13:40:30Z",
365+
},
366+
{
367+
name: "permissive parse layouts with compact encoding",
368+
opts: []Option{
369+
TimeFormat(&LayoutTimeFormatter{
370+
FormatLayout: LayoutISO8601Compact,
371+
ParseLayouts: CommonParseLayouts(),
372+
}),
373+
},
374+
expectEncode: "<dateTime.iso8601>20191011T13:40:30</dateTime.iso8601>",
375+
expectDecode: "2019-10-11T13:40:30Z",
376+
},
377+
}
378+
379+
for _, tt := range tests {
380+
t.Run(tt.name, func(t *testing.T) {
381+
serverCalled := false
382+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
383+
body, err := io.ReadAll(r.Body)
384+
require.NoError(t, err)
385+
require.Contains(t, string(body), tt.expectEncode)
386+
387+
serverCalled = true
388+
_, _ = fmt.Fprintln(w, string(loadTestFile(t, "response_datetime_compact.xml")))
389+
}))
390+
defer ts.Close()
391+
392+
c, err := NewClient(ts.URL, tt.opts...)
393+
require.NoError(t, err)
394+
395+
args := &struct{ When time.Time }{When: input}
396+
resp := &struct{ When time.Time }{}
397+
398+
err = c.Call("test.Method", args, resp)
399+
if tt.expectErr {
400+
require.ErrorContains(t, err, "does not match expected time layouts")
401+
} else {
402+
require.NoError(t, err)
403+
require.Equal(t, tt.expectDecode, resp.When.Format(time.RFC3339))
404+
}
405+
406+
require.True(t, serverCalled, "server must be called")
407+
})
408+
}
409+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?xml version="1.0"?>
2+
<methodResponse>
3+
<params>
4+
<param>
5+
<value><dateTime.iso8601>20191011T13:40:30</dateTime.iso8601></value>
6+
</param>
7+
</params>
8+
</methodResponse>
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?xml version="1.0"?>
2+
<methodResponse>
3+
<params>
4+
<param>
5+
<value><dateTime.iso8601>2019-10-11T13:40:30Z</dateTime.iso8601></value>
6+
</param>
7+
</params>
8+
</methodResponse>

0 commit comments

Comments
 (0)