Skip to content

Commit 4a54589

Browse files
authored
Parser for chrono::time_point (#648)
Co-authored-by: Ron0Studios <ron0studios@users.noreply.github.com>
1 parent 0a396fc commit 4a54589

4 files changed

Lines changed: 364 additions & 1 deletion

File tree

docs/timestamps.md

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# `rfl::Timestamp` and `std::chrono::duration`
1+
# `rfl::Timestamp`, `std::chrono::system_clock::time_point`, and `std::chrono::duration`
22

33
## `rfl::Timestamp`
44

@@ -45,6 +45,41 @@ const rfl::Result<rfl::Timestamp<"%Y-%m-%d">> result = rfl::Timestamp<"%Y-%m-%d"
4545
const rfl::Result<rfl::Timestamp<"%Y-%m-%d">> error = rfl::Timestamp<"%Y-%m-%d">::from_string("not a proper time format");
4646
```
4747

48+
## `std::chrono::system_clock::time_point`
49+
50+
`std::chrono::system_clock::time_point` is natively supported. It serializes as an ISO 8601 string with nanosecond precision:
51+
52+
```cpp
53+
struct Event {
54+
std::string name;
55+
std::chrono::system_clock::time_point created_at;
56+
};
57+
58+
rfl::json::write(Event{.name = "deploy", .created_at = std::chrono::system_clock::now()});
59+
```
60+
61+
This produces:
62+
63+
```json
64+
{"name":"deploy","created_at":"2024-01-15T12:00:00.123456789Z"}
65+
```
66+
67+
Trailing fractional zeros are stripped, so microsecond values appear as `.123456Z` and whole seconds appear without a decimal point.
68+
69+
On read, the following formats are accepted:
70+
71+
- `"2024-01-15T12:00:00Z"` — UTC, no fractional seconds
72+
- `"2024-01-15T12:00:00.123Z"` — milliseconds
73+
- `"2024-01-15T12:00:00.123456Z"` — microseconds
74+
- `"2024-01-15T12:00:00.123456789Z"` — nanoseconds
75+
- `"2024-01-15T12:00:00"` — no timezone suffix (assumed UTC)
76+
- `"2024-01-15T10:30:00+05:30"` — timezone offset (converted to UTC)
77+
- `"2024-01-15T02:00:00-08:00"` — negative offset
78+
79+
Timezone offsets are converted to UTC on read. The write path always outputs UTC with the `Z` suffix.
80+
81+
Only `std::chrono::system_clock::time_point` is supported — other clocks like `steady_clock` do not represent calendar time and cannot be serialized as ISO 8601.
82+
4883
## `std::chrono::duration`
4984

5085
`std::chrono::duration` types are serialized as an object with the count and unit as fields:

include/rfl/parsing/Parser.hpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
#include "Parser_span.hpp"
3535
#include "Parser_string_view.hpp"
3636
#include "Parser_tagged_union.hpp"
37+
#include "Parser_time_point.hpp"
3738
#include "Parser_tuple.hpp"
3839
#include "Parser_unique_ptr.hpp"
3940
#include "Parser_variant.hpp"
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
#ifndef RFL_PARSING_PARSER_TIME_POINT_HPP_
2+
#define RFL_PARSING_PARSER_TIME_POINT_HPP_
3+
4+
#include <chrono>
5+
#include <cstring>
6+
#include <ctime>
7+
#include <map>
8+
#include <optional>
9+
#include <sstream>
10+
#include <string>
11+
12+
#include "../Result.hpp"
13+
#include "Parent.hpp"
14+
#include "Parser_base.hpp"
15+
#include "schema/Type.hpp"
16+
17+
namespace rfl::parsing {
18+
19+
template <class R, class W, class Duration, class ProcessorsType>
20+
requires AreReaderAndWriter<
21+
R, W, std::chrono::time_point<std::chrono::system_clock, Duration>>
22+
struct Parser<R, W,
23+
std::chrono::time_point<std::chrono::system_clock, Duration>,
24+
ProcessorsType> {
25+
public:
26+
using InputVarType = typename R::InputVarType;
27+
28+
using ParentType = Parent<W>;
29+
30+
using TimePointType =
31+
std::chrono::time_point<std::chrono::system_clock, Duration>;
32+
33+
static Result<TimePointType> read(const R& _r,
34+
const InputVarType& _var) noexcept {
35+
return Parser<R, W, std::string, ProcessorsType>::read(_r, _var).and_then(
36+
from_string);
37+
}
38+
39+
template <class P>
40+
static void write(const W& _w, const TimePointType& _tp, const P& _parent) {
41+
Parser<R, W, std::string, ProcessorsType>::write(_w, to_string(_tp),
42+
_parent);
43+
}
44+
45+
static schema::Type to_schema(
46+
std::map<std::string, schema::Type>* _definitions) {
47+
return Parser<R, W, std::string, ProcessorsType>::to_schema(_definitions);
48+
}
49+
50+
private:
51+
static std::string to_string(const TimePointType& _tp) {
52+
const auto sys_time =
53+
std::chrono::time_point_cast<std::chrono::nanoseconds>(_tp);
54+
const auto epoch = sys_time.time_since_epoch();
55+
const auto secs = std::chrono::duration_cast<std::chrono::seconds>(epoch);
56+
const auto nsecs =
57+
std::chrono::duration_cast<std::chrono::nanoseconds>(epoch - secs);
58+
59+
auto t = static_cast<std::time_t>(secs.count());
60+
std::tm tm{};
61+
#if defined(_MSC_VER) || defined(__MINGW32__)
62+
gmtime_s(&tm, &t);
63+
#else
64+
gmtime_r(&t, &tm);
65+
#endif
66+
67+
char buf[32];
68+
strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S", &tm);
69+
70+
const auto ns = nsecs.count();
71+
if (ns != 0) {
72+
char frac[16];
73+
// Write nanoseconds, then strip trailing zeros.
74+
snprintf(frac, sizeof(frac), ".%09lld",
75+
static_cast<long long>(ns < 0 ? -ns : ns));
76+
auto len = strlen(frac);
77+
while (len > 1 && frac[len - 1] == '0') {
78+
--len;
79+
}
80+
frac[len] = '\0';
81+
return std::string(buf) + frac + "Z";
82+
}
83+
return std::string(buf) + "Z";
84+
}
85+
86+
static Result<TimePointType> from_string(const std::string& _str) noexcept {
87+
try {
88+
std::tm tm{};
89+
const char* str = _str.c_str();
90+
const char* rest = parse_datetime(str, &tm);
91+
if (!rest) {
92+
return error("Could not parse time point from '" + _str + "'.");
93+
}
94+
95+
auto t = to_time_t(tm);
96+
auto tp = std::chrono::system_clock::from_time_t(t);
97+
98+
// Parse fractional seconds if present.
99+
if (*rest == '.') {
100+
++rest;
101+
long long frac = 0;
102+
int digits = 0;
103+
while (*rest >= '0' && *rest <= '9' && digits < 9) {
104+
frac = frac * 10 + (*rest - '0');
105+
++rest;
106+
++digits;
107+
}
108+
// Pad to nanoseconds (9 digits).
109+
while (digits < 9) {
110+
frac *= 10;
111+
++digits;
112+
}
113+
// Truncate beyond nanoseconds.
114+
while (digits > 9) {
115+
frac /= 10;
116+
--digits;
117+
}
118+
tp += std::chrono::duration_cast<std::chrono::system_clock::duration>(
119+
std::chrono::nanoseconds(frac));
120+
}
121+
122+
// Parse timezone: 'Z', '+HH:MM', '-HH:MM', or end of string.
123+
if (*rest == '+' || *rest == '-') {
124+
const auto offset = parse_tz_offset(rest);
125+
if (!offset) {
126+
return error("Could not parse timezone offset from '" + _str + "'.");
127+
}
128+
tp -= *offset;
129+
} else if (*rest != 'Z' && *rest != '\0') {
130+
return error("Could not parse time point from '" + _str +
131+
"': expected 'Z', timezone offset, or end of string.");
132+
}
133+
134+
return std::chrono::time_point_cast<Duration>(tp);
135+
} catch (std::exception& e) {
136+
return error(e.what());
137+
}
138+
}
139+
140+
static bool is_digit(char c) { return c >= '0' && c <= '9'; }
141+
142+
static int two_digits(const char* s) {
143+
return (s[0] - '0') * 10 + (s[1] - '0');
144+
}
145+
146+
/// Parses a timezone offset like "+05:30" or "-08:00".
147+
/// Returns the offset as a chrono duration, or std::nullopt on failure.
148+
static std::optional<std::chrono::minutes> parse_tz_offset(const char* _str) {
149+
if (*_str != '+' && *_str != '-') {
150+
return std::nullopt;
151+
}
152+
const int sign = (*_str == '+') ? 1 : -1;
153+
++_str;
154+
// Expect HH:MM or HHMM.
155+
if (!is_digit(_str[0]) || !is_digit(_str[1])) {
156+
return std::nullopt;
157+
}
158+
const int hours = two_digits(_str);
159+
_str += 2;
160+
if (*_str == ':') {
161+
++_str;
162+
}
163+
if (!is_digit(_str[0]) || !is_digit(_str[1])) {
164+
return std::nullopt;
165+
}
166+
const int minutes = two_digits(_str);
167+
return std::chrono::minutes(sign * (hours * 60 + minutes));
168+
}
169+
170+
static const char* parse_datetime(const char* _str, std::tm* _tm) {
171+
#if defined(_MSC_VER) || defined(__MINGW32__)
172+
std::istringstream input(_str);
173+
input.imbue(std::locale::classic());
174+
input >> std::get_time(_tm, "%Y-%m-%dT%H:%M:%S");
175+
if (input.fail()) {
176+
return nullptr;
177+
}
178+
const auto pos = input.tellg();
179+
if (pos == std::streampos(-1)) {
180+
// Stream reached EOF after parsing — all input was consumed.
181+
return _str + std::strlen(_str);
182+
}
183+
return _str + static_cast<std::ptrdiff_t>(pos);
184+
#else
185+
return strptime(_str, "%Y-%m-%dT%H:%M:%S", _tm);
186+
#endif
187+
}
188+
189+
static std::time_t to_time_t(std::tm& _tm) {
190+
#if defined(_MSC_VER) || defined(__MINGW32__)
191+
return _mkgmtime(&_tm);
192+
#else
193+
return timegm(&_tm);
194+
#endif
195+
}
196+
};
197+
198+
} // namespace rfl::parsing
199+
200+
#endif

tests/json/test_time_point.cpp

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
#include <gtest/gtest.h>
2+
3+
#include <chrono>
4+
#include <rfl.hpp>
5+
#include <rfl/json.hpp>
6+
#include <string>
7+
8+
namespace test_time_point {
9+
10+
struct Event {
11+
std::string name;
12+
std::chrono::system_clock::time_point created_at;
13+
};
14+
15+
TEST(json, test_time_point_round_trip) {
16+
const auto now = std::chrono::system_clock::now();
17+
const auto event = Event{.name = "deploy", .created_at = now};
18+
19+
const auto json = rfl::json::write(event);
20+
const auto result = rfl::json::read<Event>(json);
21+
22+
ASSERT_TRUE(result && true) << result.error().what();
23+
EXPECT_EQ(result.value().name, "deploy");
24+
25+
// Compare at the system clock's native resolution.
26+
const auto expected =
27+
std::chrono::time_point_cast<std::chrono::system_clock::duration>(now);
28+
const auto actual =
29+
std::chrono::time_point_cast<std::chrono::system_clock::duration>(
30+
result.value().created_at);
31+
EXPECT_EQ(expected, actual);
32+
}
33+
34+
TEST(json, test_time_point_format) {
35+
// 2024-01-15T12:00:00.123456Z
36+
const auto epoch = std::chrono::system_clock::from_time_t(1705320000);
37+
const auto tp = epoch + std::chrono::microseconds(123456);
38+
const auto event = Event{.name = "test", .created_at = tp};
39+
40+
const auto json = rfl::json::write(event);
41+
EXPECT_TRUE(json.find(".123456Z") != std::string::npos) << "Got: " << json;
42+
43+
// Verify round-trip preserves the exact microseconds.
44+
const auto result = rfl::json::read<Event>(json);
45+
ASSERT_TRUE(result && true) << result.error().what();
46+
EXPECT_EQ(std::chrono::time_point_cast<std::chrono::microseconds>(tp),
47+
std::chrono::time_point_cast<std::chrono::microseconds>(
48+
result.value().created_at));
49+
}
50+
51+
TEST(json, test_time_point_no_fractional) {
52+
const auto tp = std::chrono::system_clock::from_time_t(1705320000);
53+
const auto event = Event{.name = "test", .created_at = tp};
54+
55+
const auto json = rfl::json::write(event);
56+
// Should not have fractional seconds.
57+
EXPECT_TRUE(json.find("\"Z\"") == std::string::npos)
58+
<< "Should not be quoted Z";
59+
EXPECT_TRUE(json.find(".") == std::string::npos)
60+
<< "Should not have fractional part. Got: " << json;
61+
}
62+
63+
TEST(json, test_time_point_parse_various_precisions) {
64+
// Milliseconds.
65+
auto r1 = rfl::json::read<Event>(
66+
R"({"name":"a","created_at":"2024-01-15T10:30:00.123Z"})");
67+
ASSERT_TRUE(r1 && true) << r1.error().what();
68+
69+
// Nanoseconds.
70+
auto r2 = rfl::json::read<Event>(
71+
R"({"name":"b","created_at":"2024-01-15T10:30:00.123456789Z"})");
72+
ASSERT_TRUE(r2 && true) << r2.error().what();
73+
74+
// No fractional part.
75+
auto r3 = rfl::json::read<Event>(
76+
R"({"name":"c","created_at":"2024-01-15T10:30:00Z"})");
77+
ASSERT_TRUE(r3 && true) << r3.error().what();
78+
}
79+
80+
TEST(json, test_time_point_reject_invalid_suffix) {
81+
// Trailing garbage should fail.
82+
auto r1 = rfl::json::read<Event>(
83+
R"({"name":"a","created_at":"2024-01-15T10:30:00Invalid"})");
84+
EXPECT_FALSE(r1 && true);
85+
86+
// No Z is accepted (end of string).
87+
auto r2 = rfl::json::read<Event>(
88+
R"({"name":"b","created_at":"2024-01-15T10:30:00"})");
89+
EXPECT_TRUE(r2 && true) << r2.error().what();
90+
}
91+
92+
TEST(json, test_time_point_timezone_offset) {
93+
// +05:30 means 5h30m ahead of UTC, so 10:30+05:30 = 05:00Z.
94+
auto r1 = rfl::json::read<Event>(
95+
R"({"name":"a","created_at":"2024-01-15T10:30:00+05:30"})");
96+
ASSERT_TRUE(r1 && true) << r1.error().what();
97+
98+
auto r_utc = rfl::json::read<Event>(
99+
R"({"name":"a","created_at":"2024-01-15T05:00:00Z"})");
100+
ASSERT_TRUE(r_utc && true) << r_utc.error().what();
101+
102+
EXPECT_EQ(
103+
std::chrono::time_point_cast<std::chrono::seconds>(r1.value().created_at),
104+
std::chrono::time_point_cast<std::chrono::seconds>(
105+
r_utc.value().created_at));
106+
107+
// Negative offset: -08:00 means 8h behind UTC, so 02:00-08:00 = 10:00Z.
108+
auto r2 = rfl::json::read<Event>(
109+
R"({"name":"b","created_at":"2024-01-15T02:00:00-08:00"})");
110+
ASSERT_TRUE(r2 && true) << r2.error().what();
111+
112+
auto r_utc2 = rfl::json::read<Event>(
113+
R"({"name":"b","created_at":"2024-01-15T10:00:00Z"})");
114+
ASSERT_TRUE(r_utc2 && true) << r_utc2.error().what();
115+
116+
EXPECT_EQ(
117+
std::chrono::time_point_cast<std::chrono::seconds>(r2.value().created_at),
118+
std::chrono::time_point_cast<std::chrono::seconds>(
119+
r_utc2.value().created_at));
120+
121+
// Offset with fractional seconds.
122+
auto r3 = rfl::json::read<Event>(
123+
R"({"name":"c","created_at":"2024-01-15T10:30:00.5+05:30"})");
124+
ASSERT_TRUE(r3 && true) << r3.error().what();
125+
}
126+
127+
} // namespace test_time_point

0 commit comments

Comments
 (0)