diff --git a/docs/timestamps.md b/docs/timestamps.md index 26b73241..6f1474ea 100644 --- a/docs/timestamps.md +++ b/docs/timestamps.md @@ -1,4 +1,4 @@ -# `rfl::Timestamp` and `std::chrono::duration` +# `rfl::Timestamp`, `std::chrono::system_clock::time_point`, and `std::chrono::duration` ## `rfl::Timestamp` @@ -45,6 +45,41 @@ const rfl::Result> result = rfl::Timestamp<"%Y-%m-%d" const rfl::Result> error = rfl::Timestamp<"%Y-%m-%d">::from_string("not a proper time format"); ``` +## `std::chrono::system_clock::time_point` + +`std::chrono::system_clock::time_point` is natively supported. It serializes as an ISO 8601 string with nanosecond precision: + +```cpp +struct Event { + std::string name; + std::chrono::system_clock::time_point created_at; +}; + +rfl::json::write(Event{.name = "deploy", .created_at = std::chrono::system_clock::now()}); +``` + +This produces: + +```json +{"name":"deploy","created_at":"2024-01-15T12:00:00.123456789Z"} +``` + +Trailing fractional zeros are stripped, so microsecond values appear as `.123456Z` and whole seconds appear without a decimal point. + +On read, the following formats are accepted: + +- `"2024-01-15T12:00:00Z"` — UTC, no fractional seconds +- `"2024-01-15T12:00:00.123Z"` — milliseconds +- `"2024-01-15T12:00:00.123456Z"` — microseconds +- `"2024-01-15T12:00:00.123456789Z"` — nanoseconds +- `"2024-01-15T12:00:00"` — no timezone suffix (assumed UTC) +- `"2024-01-15T10:30:00+05:30"` — timezone offset (converted to UTC) +- `"2024-01-15T02:00:00-08:00"` — negative offset + +Timezone offsets are converted to UTC on read. The write path always outputs UTC with the `Z` suffix. + +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. + ## `std::chrono::duration` `std::chrono::duration` types are serialized as an object with the count and unit as fields: diff --git a/include/rfl/parsing/Parser.hpp b/include/rfl/parsing/Parser.hpp index 81eeff30..5699132e 100644 --- a/include/rfl/parsing/Parser.hpp +++ b/include/rfl/parsing/Parser.hpp @@ -34,6 +34,7 @@ #include "Parser_span.hpp" #include "Parser_string_view.hpp" #include "Parser_tagged_union.hpp" +#include "Parser_time_point.hpp" #include "Parser_tuple.hpp" #include "Parser_unique_ptr.hpp" #include "Parser_variant.hpp" diff --git a/include/rfl/parsing/Parser_time_point.hpp b/include/rfl/parsing/Parser_time_point.hpp new file mode 100644 index 00000000..22fc7487 --- /dev/null +++ b/include/rfl/parsing/Parser_time_point.hpp @@ -0,0 +1,200 @@ +#ifndef RFL_PARSING_PARSER_TIME_POINT_HPP_ +#define RFL_PARSING_PARSER_TIME_POINT_HPP_ + +#include +#include +#include +#include +#include +#include +#include + +#include "../Result.hpp" +#include "Parent.hpp" +#include "Parser_base.hpp" +#include "schema/Type.hpp" + +namespace rfl::parsing { + +template + requires AreReaderAndWriter< + R, W, std::chrono::time_point> +struct Parser, + ProcessorsType> { + public: + using InputVarType = typename R::InputVarType; + + using ParentType = Parent; + + using TimePointType = + std::chrono::time_point; + + static Result read(const R& _r, + const InputVarType& _var) noexcept { + return Parser::read(_r, _var).and_then( + from_string); + } + + template + static void write(const W& _w, const TimePointType& _tp, const P& _parent) { + Parser::write(_w, to_string(_tp), + _parent); + } + + static schema::Type to_schema( + std::map* _definitions) { + return Parser::to_schema(_definitions); + } + + private: + static std::string to_string(const TimePointType& _tp) { + const auto sys_time = + std::chrono::time_point_cast(_tp); + const auto epoch = sys_time.time_since_epoch(); + const auto secs = std::chrono::duration_cast(epoch); + const auto nsecs = + std::chrono::duration_cast(epoch - secs); + + auto t = static_cast(secs.count()); + std::tm tm{}; +#if defined(_MSC_VER) || defined(__MINGW32__) + gmtime_s(&tm, &t); +#else + gmtime_r(&t, &tm); +#endif + + char buf[32]; + strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S", &tm); + + const auto ns = nsecs.count(); + if (ns != 0) { + char frac[16]; + // Write nanoseconds, then strip trailing zeros. + snprintf(frac, sizeof(frac), ".%09lld", + static_cast(ns < 0 ? -ns : ns)); + auto len = strlen(frac); + while (len > 1 && frac[len - 1] == '0') { + --len; + } + frac[len] = '\0'; + return std::string(buf) + frac + "Z"; + } + return std::string(buf) + "Z"; + } + + static Result from_string(const std::string& _str) noexcept { + try { + std::tm tm{}; + const char* str = _str.c_str(); + const char* rest = parse_datetime(str, &tm); + if (!rest) { + return error("Could not parse time point from '" + _str + "'."); + } + + auto t = to_time_t(tm); + auto tp = std::chrono::system_clock::from_time_t(t); + + // Parse fractional seconds if present. + if (*rest == '.') { + ++rest; + long long frac = 0; + int digits = 0; + while (*rest >= '0' && *rest <= '9' && digits < 9) { + frac = frac * 10 + (*rest - '0'); + ++rest; + ++digits; + } + // Pad to nanoseconds (9 digits). + while (digits < 9) { + frac *= 10; + ++digits; + } + // Truncate beyond nanoseconds. + while (digits > 9) { + frac /= 10; + --digits; + } + tp += std::chrono::duration_cast( + std::chrono::nanoseconds(frac)); + } + + // Parse timezone: 'Z', '+HH:MM', '-HH:MM', or end of string. + if (*rest == '+' || *rest == '-') { + const auto offset = parse_tz_offset(rest); + if (!offset) { + return error("Could not parse timezone offset from '" + _str + "'."); + } + tp -= *offset; + } else if (*rest != 'Z' && *rest != '\0') { + return error("Could not parse time point from '" + _str + + "': expected 'Z', timezone offset, or end of string."); + } + + return std::chrono::time_point_cast(tp); + } catch (std::exception& e) { + return error(e.what()); + } + } + + static bool is_digit(char c) { return c >= '0' && c <= '9'; } + + static int two_digits(const char* s) { + return (s[0] - '0') * 10 + (s[1] - '0'); + } + + /// Parses a timezone offset like "+05:30" or "-08:00". + /// Returns the offset as a chrono duration, or std::nullopt on failure. + static std::optional parse_tz_offset(const char* _str) { + if (*_str != '+' && *_str != '-') { + return std::nullopt; + } + const int sign = (*_str == '+') ? 1 : -1; + ++_str; + // Expect HH:MM or HHMM. + if (!is_digit(_str[0]) || !is_digit(_str[1])) { + return std::nullopt; + } + const int hours = two_digits(_str); + _str += 2; + if (*_str == ':') { + ++_str; + } + if (!is_digit(_str[0]) || !is_digit(_str[1])) { + return std::nullopt; + } + const int minutes = two_digits(_str); + return std::chrono::minutes(sign * (hours * 60 + minutes)); + } + + static const char* parse_datetime(const char* _str, std::tm* _tm) { +#if defined(_MSC_VER) || defined(__MINGW32__) + std::istringstream input(_str); + input.imbue(std::locale::classic()); + input >> std::get_time(_tm, "%Y-%m-%dT%H:%M:%S"); + if (input.fail()) { + return nullptr; + } + const auto pos = input.tellg(); + if (pos == std::streampos(-1)) { + // Stream reached EOF after parsing — all input was consumed. + return _str + std::strlen(_str); + } + return _str + static_cast(pos); +#else + return strptime(_str, "%Y-%m-%dT%H:%M:%S", _tm); +#endif + } + + static std::time_t to_time_t(std::tm& _tm) { +#if defined(_MSC_VER) || defined(__MINGW32__) + return _mkgmtime(&_tm); +#else + return timegm(&_tm); +#endif + } +}; + +} // namespace rfl::parsing + +#endif diff --git a/tests/json/test_time_point.cpp b/tests/json/test_time_point.cpp new file mode 100644 index 00000000..01f03260 --- /dev/null +++ b/tests/json/test_time_point.cpp @@ -0,0 +1,127 @@ +#include + +#include +#include +#include +#include + +namespace test_time_point { + +struct Event { + std::string name; + std::chrono::system_clock::time_point created_at; +}; + +TEST(json, test_time_point_round_trip) { + const auto now = std::chrono::system_clock::now(); + const auto event = Event{.name = "deploy", .created_at = now}; + + const auto json = rfl::json::write(event); + const auto result = rfl::json::read(json); + + ASSERT_TRUE(result && true) << result.error().what(); + EXPECT_EQ(result.value().name, "deploy"); + + // Compare at the system clock's native resolution. + const auto expected = + std::chrono::time_point_cast(now); + const auto actual = + std::chrono::time_point_cast( + result.value().created_at); + EXPECT_EQ(expected, actual); +} + +TEST(json, test_time_point_format) { + // 2024-01-15T12:00:00.123456Z + const auto epoch = std::chrono::system_clock::from_time_t(1705320000); + const auto tp = epoch + std::chrono::microseconds(123456); + const auto event = Event{.name = "test", .created_at = tp}; + + const auto json = rfl::json::write(event); + EXPECT_TRUE(json.find(".123456Z") != std::string::npos) << "Got: " << json; + + // Verify round-trip preserves the exact microseconds. + const auto result = rfl::json::read(json); + ASSERT_TRUE(result && true) << result.error().what(); + EXPECT_EQ(std::chrono::time_point_cast(tp), + std::chrono::time_point_cast( + result.value().created_at)); +} + +TEST(json, test_time_point_no_fractional) { + const auto tp = std::chrono::system_clock::from_time_t(1705320000); + const auto event = Event{.name = "test", .created_at = tp}; + + const auto json = rfl::json::write(event); + // Should not have fractional seconds. + EXPECT_TRUE(json.find("\"Z\"") == std::string::npos) + << "Should not be quoted Z"; + EXPECT_TRUE(json.find(".") == std::string::npos) + << "Should not have fractional part. Got: " << json; +} + +TEST(json, test_time_point_parse_various_precisions) { + // Milliseconds. + auto r1 = rfl::json::read( + R"({"name":"a","created_at":"2024-01-15T10:30:00.123Z"})"); + ASSERT_TRUE(r1 && true) << r1.error().what(); + + // Nanoseconds. + auto r2 = rfl::json::read( + R"({"name":"b","created_at":"2024-01-15T10:30:00.123456789Z"})"); + ASSERT_TRUE(r2 && true) << r2.error().what(); + + // No fractional part. + auto r3 = rfl::json::read( + R"({"name":"c","created_at":"2024-01-15T10:30:00Z"})"); + ASSERT_TRUE(r3 && true) << r3.error().what(); +} + +TEST(json, test_time_point_reject_invalid_suffix) { + // Trailing garbage should fail. + auto r1 = rfl::json::read( + R"({"name":"a","created_at":"2024-01-15T10:30:00Invalid"})"); + EXPECT_FALSE(r1 && true); + + // No Z is accepted (end of string). + auto r2 = rfl::json::read( + R"({"name":"b","created_at":"2024-01-15T10:30:00"})"); + EXPECT_TRUE(r2 && true) << r2.error().what(); +} + +TEST(json, test_time_point_timezone_offset) { + // +05:30 means 5h30m ahead of UTC, so 10:30+05:30 = 05:00Z. + auto r1 = rfl::json::read( + R"({"name":"a","created_at":"2024-01-15T10:30:00+05:30"})"); + ASSERT_TRUE(r1 && true) << r1.error().what(); + + auto r_utc = rfl::json::read( + R"({"name":"a","created_at":"2024-01-15T05:00:00Z"})"); + ASSERT_TRUE(r_utc && true) << r_utc.error().what(); + + EXPECT_EQ( + std::chrono::time_point_cast(r1.value().created_at), + std::chrono::time_point_cast( + r_utc.value().created_at)); + + // Negative offset: -08:00 means 8h behind UTC, so 02:00-08:00 = 10:00Z. + auto r2 = rfl::json::read( + R"({"name":"b","created_at":"2024-01-15T02:00:00-08:00"})"); + ASSERT_TRUE(r2 && true) << r2.error().what(); + + auto r_utc2 = rfl::json::read( + R"({"name":"b","created_at":"2024-01-15T10:00:00Z"})"); + ASSERT_TRUE(r_utc2 && true) << r_utc2.error().what(); + + EXPECT_EQ( + std::chrono::time_point_cast(r2.value().created_at), + std::chrono::time_point_cast( + r_utc2.value().created_at)); + + // Offset with fractional seconds. + auto r3 = rfl::json::read( + R"({"name":"c","created_at":"2024-01-15T10:30:00.5+05:30"})"); + ASSERT_TRUE(r3 && true) << r3.error().what(); +} + +} // namespace test_time_point