Info - TLDR
I wrote my own ISO8601 duration parsing library because the common ones sucked.
ISO8601 durations are defined as P[nn]Y[nn]M[nn]DT[nn]H[nn]M[nn]S
or
P[nn]W
.
See:
I’m currently working on a project that requires interacting with the api of a
german public transport provider, specifically vbb. To abstract this I wrote
go-hafas, however, all fields that
include duration
are serialized to something along the lines of PT3M
, for
instance:
1{
2 // ...
3
4 // means something like between two stops you require at least this amount
5 // to successfully walk from the previous arrival to the next departure
6 "minimumChangeDuration": "PT2M",
7 // ...
8}
To use these durations for something like UI, sorting, or internal logic, I need to parse them into something Go native.
No Support In Go’s Stdlib
I thought, sure, I’ll just use
time.Duration
and parse the format via
time.ParseDuration
, until I found out:
There is no ISO8601 support in either
time.Parse
(there is support for an ISO8601 subset:time.RFC3339
, just not for a duration) ortime.DurationParse
, which only accepts “custom” duration strings someone at google came up with I guessGO1// ParseDuration parses a duration string. 2// A duration string is a possibly signed sequence of 3// decimal numbers, each with optional fraction and a unit suffix, 4// such as "300ms", "-1.5h" or "2h45m". 5// Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h". 6func ParseDuration(s string) (Duration, error) {
time.Duration
is only backed internally by aint64
, this doesn’t mean it can’t contain the ISO8601 duration spec, just fun to knowGO1// A Duration represents the elapsed time between two instants 2// as an int64 nanosecond count. The representation limits the 3// largest representable duration to approximately 290 years. 4type Duration int64
No “well done” library
So I went looking for a library, which left me dumbfounded, because none of
them supported my obvious use case (converting a ISO8601 duration to
time.Duration
) and some weren’t even spec compliant (see next chapter):
Library | Why I didnt chose it |
---|---|
dacut/go-iso8601 | No Duration support |
relvacode/iso8601 | No Duration support |
sosodev/duration | Accepts faulty inputs, doesn’t treat W as exclusive |
senseyeio/duration | Uses regex (preference), Doesn’t treat W as exclusive |
So I, as every sensible software developer would, started to write my own library: go-iso8601-duration.
Finding the Specification
This is the part where I was severly pissed off. First of all, I understand why the go stdlib only supports a ISO8601 subset, because: RFC3339 is freely accessible and ISO8601 is a cluster fuck of backwards compatibility and absurd formats. Cutting the fat down to a subset of format strings was a good decision. I can only recommend RFC 3339 vs ISO 8601 vs HTML, the visualisation shows the whole scale of cursedness ISO8601 and HTML bring to the world. Also, technically RFC3339 defines ISO8601 durations while not going into further detail on them anywhere, see RFC3339 - Appendix A. ISO 8601 Collected ABNF.
Germany (the country I am from and pay taxes to), contributes 7.6% of the OECD budget per anno, applying this to membership fees of ISO’s financial performance in 2023:
Operating revenue | 2023 |
---|---|
Membership fees | 21 359 |
Royalties received from members selling ISO standards | 14 949 |
Revenue from members | 36 308 |
Revenue – net sales | 6 620 |
Funded activities | |
Funds to support Capacity Building activities | 2 620 |
Funds to support ISO Strategy | 1 800 |
Funded activities - revenue | 4 420 |
Total revenue | 47 348 |
We get a staggering \(21359 \cdot 0.076 = 1623.284\)kCHF, which is around \(1,737,227.20\textrm{€}\) of taxpayers money I contribute to. As a german I love standards and the idea behind ISO and DIN (Germanys ISO member body). What I don’t love is my taxes going to a organisation that produces standards that aren’t freely accessible for anyone, not for library implementers, not for oss contributors and not even for the tax payers financing said organisation. To add insult to inury, they fucking charge 227€ for the current ISO8601 version: ISO 8601-2:2019-02. Even the one before that costs 80 bucks. Anyway, I used the very good wikipedia ISO8601 - Durations and found some public versions of the full spec (ISO8601 - 4.4.3 Duration), out of all places, from the us library of congress .
Understanding the Specification
4.4.1 Separators and designators requires: “[…] P shall precede […] the remainder of the […] duration”.
4.4.3.1 General defines a Duration as “[…] a combination of components with accurate duration (hour, minute and second) and components with nominal duration (year, month, week and day).”. A component is made up of a variable amount of numeric characters and a designator.
4.4.3.2 Format with designators defines this further: “The number of years shall be followed by […] Y”, “the number of months by M”, “the number of weeks by W”, “and the number of days by D”. “[…] [the] time components shall be preceded by […] T”. It also defines “[…] number of hours […] by H”, “[…] minutes by M” and “[…] seconds by S”.
Furthermore “[…] both basic and extended format […] shall be PnnW or PnnYnnMnnDTnnHnnMnnS”. The count of numeric characters (nn) before a designator is somehow agreed upon by the members of the information exchange, so even something absurd like 256 would be valid, who even thought that would be smart? There are also some exceptions to the before:
- “[…] components may be omitted […]”
- “if necessary for a particular application, the lowest order components may have a decimal fraction” (I skipped this one, because I don’t think this is a good feature, but I’m open to adding this in the future, if necessary)
- “[…][if no other component is present] at least one number and its designator shall be present”
- “[…] T shall be absent if all time components are absent”
Summary
- A duration format string must start with a
P
for period - There are the following designators for the date component:
- Y: year
- M: month
- D: day
- There are the following designators for the time component:
- H: hour
- M: minute
- S: second
- Each designator needs a number and each number needs a designator
- Pairs can be omitted, if empty
- The time component must start with
T
, but can be omitted if no time designators are found - Using
W
is exclusive to all other designators - There always needs to be at least one designator and number pair
And some examples for the above:
Example | Pair wise |
---|---|
P1Y2M3DT1H2M3S | P 1Y 2M 3D T 1H 2M 3S |
P1Y2M3D | P 1Y 2M 3D |
PT1H2M3S | P T 1H 2M 3S |
P56W | P 56W |
P0D | P 0D |
Handrolling ISO8601 Durations
After having somewhat understood the spec, lets get started with going through my process of implementing the library.
Defining a Duration Container
1type ISO8601Duration struct {
2 year, month, week, day, hour, minute, second float64
3}
The API
A simple interaction interface:
1// P[nn]Y[nn]M[nn]DT[nn]H[nn]M[nn]S or P[nn]W, as seen in
2// https://en.wikipedia.org/wiki/ISO_8601#Durations
3//
4// - P is the duration designator (for period) placed at the start of the duration representation.
5// - Y is the year designator that follows the value for the number of calendar years.
6// - M is the month designator that follows the value for the number of calendar months.
7// - W is the week designator that follows the value for the number of weeks.
8// - D is the day designator that follows the value for the number of calendar days.
9//
10// - T is the time designator that precedes the time components of the representation.
11// - H is the hour designator that follows the value for the number of hours.
12// - M is the minute designator that follows the value for the number of minutes.
13// - S is the second designator that follows the value for the number of seconds.
14func From(s string) (ISO8601Duration, error) {}
15func (i ISO8601Duration) Apply(t time.Time) time.Time {}
16func (i ISO8601Duration) Duration() time.Duration {}
Using it as below:
1package main
2
3import (
4 "fmt"
5 "time"
6
7 "github.com/xnacly/go-iso8601-duration"
8)
9
10func main() {
11 rawDuration := "PT1H30M12S"
12 duration, err := goiso8601duration.From(rawDuration)
13 if err != nil {
14 panic(err)
15 }
16
17 // 1h30m12s PT1H30M12S
18 fmt.Println(duration.Duration().String(), duration.String())
19 // 01:00:00 02:30:12
20 fmt.Println(
21 time.
22 Unix(0, 0).
23 Format(time.TimeOnly),
24 duration.
25 Apply(time.Unix(0, 0)).
26 Format(time.TimeOnly),
27 )
28}
I’m using this in production to interact with hafas data and so far I’m pretty happy with it.
Writing a Finite State Machine
The basis of any state machine, finite or not, is to define states the machine should operate on. For a more theoretical deep dive, do read the wikipedia article on finite state machines, its really good.
In general, the set of states is often defined by the differing input components a format can have. Each state machine should have a start and an end state. This makes bookkeeping for setting up internal state in the fsm easier to reason about.
Statify ISO8601
In the ISO8601 duration case, we of course also have the starting character,
P
, which is required. The other atypical character, is the optional T
which
instructs the parser to switch to time mode.
After these initial states and the atypical states, we can go over to the pairs
of number and designator the duration format uses. We have to differentiate
between designators and numbers while in time and default mode, for this we use
stateTNumber
and stateTDesignator
in contrast to stateNumber
and
stateDesignator
:
1type state = uint8
2
3const (
4 stateStart state = iota
5 stateP
6
7 stateNumber
8 stateDesignator
9
10 stateT
11 stateTNumber
12 stateTDesignator
13
14 stateFin
15)
Since I like to have a mathematical definition for things like these, we can pull up our mathematical fsm model. Its basically a quintuple of:
\(\Sigma\): Input alphabet:
$$ \{ P, T, Y, M, W, D, H, S, 0..9 \} $$\(S\): finite non-empty set of states:
$$ \{ start, P, Number, Designator, T, TNumber, TDesignator, fin \} $$\(s_0\): initial state, \(s_0 \in S\):
$$ start $$\(\delta\): state transition function, which maps any state to its following state
$$ \delta := S \times \Sigma \rightarrow S $$However, since we need to be able to reject inputs, see Dealing with unexpected state transitions, we can redefine \(\delta\) into a partial function as follows:
$$ \begin{align} \delta(s, x) &:= S \times \Sigma \rightarrow S \\ &\Leftrightarrow \begin{cases} P & \quad x = P, s = start \\ T & \quad x = T, s \in \{P, Y, M, D, Designator\} \\ Number & \quad x \in \{0..9\}, s \in \{P, Y, M, D, Designator\} \\ Designator & \quad x \in \{Y,M,D,W\}, s \in \{Number\} \\ TNumber & \quad x \in \{0..9\}, s \in \{T,TDesignator\} \\ TDesignator & \quad x \in \{H,M,S\}, s \in \{TNumber\} \\ fin & \quad \text{otherwise} \end{cases} \end{align} $$\(F\): the final set of states:
$$ \{ fin \} $$
State transitions
Stepping through ISO8601
Lets take for instance P25W
and step through the fsm for this example:
\(s\) | \(x\) | \(\delta(s,x) \Rightarrow s_0\) | Input |
---|---|---|---|
start | P | P | P25W |
P | 2 | Number | 25W |
Number | 5 | Number | 5W |
Number | W | Designator | W |
Designator | EOF | fin |
|
The same applies for formats including time components, like PT2M3S
\(s\) | \(x\) | \(\delta(s,x) \Rightarrow s_0\) | Input |
---|---|---|---|
start | P | P | PT2M3S |
P | T | T | T2M3S |
T | 2 | TNumber | 2M3S |
TNumber | M | TDesignator | M3S |
TDesignator | 3 | TNumber | 3S |
TNumber | S | TDesignator | S |
TDesignator | EOF | fin |
|
Dealing with unexpected state transitions
Since I want to provide myself very extensive error contexts and I was
disappointed with the usual unexpected input
shenanigans, I whipped up the
following error constants and the ISO8601DurationError
for wrapping it with
the position the error occured in:
1package goiso8601duration
2
3import (
4 "errors"
5 "fmt"
6)
7
8var (
9 UnexpectedEof = errors.New("Unexpected EOF in duration format string")
10 UnexpectedReaderError = errors.New("Failed to retrieve next byte of duration format string")
11 UnexpectedNonAsciiRune = errors.New("Unexpected non ascii component in duration format string")
12 MissingDesignator = errors.New("Missing unit designator")
13 UnknownDesignator = errors.New("Unknown designator, expected YMWD or after a T, HMS")
14 DuplicateDesignator = errors.New("Duplicate designator")
15 MissingNumber = errors.New("Missing number specifier before unit designator")
16 TooManyNumbersForDesignator = errors.New("Only 2 numbers before any designator allowed")
17 MissingPDesignatorAtStart = errors.New("Missing [P] designator at the start of the duration format string")
18 NoDesignatorsAfterWeeksAllowed = errors.New("After [W] designator, no other numbers and designators are allowed")
19)
20
21type ISO8601DurationError struct {
22 Inner error
23 Column uint8
24}
25
26func wrapErr(inner error, col uint8) error {
27 return ISO8601DurationError{
28 Inner: inner,
29 Column: col,
30 }
31}
32
33func (i ISO8601DurationError) String() string {
34 return fmt.Sprint("ISO8601DurationError: ", i.Inner, ", at col: ", i.Column)
35}
36
37func (i ISO8601DurationError) Error() string {
38 return i.String()
39}
These errors already hint at all the errors the fsm can encounter, lets go top to bottom and contextualize each:
UnexpectedEof
: not much to say here, the duration string ended unexpectedlyUnexpectedReaderError
: reading a new rune from the input failedUnexpectedNonAsciiRune
: a read rune wasn’t ascii, which a ISO8601 duration solely consists ofMissingDesignator
: the fsm encountered a number but no matching designator, for instance something likeP5
instead ofP5D
UnknownDesignator
: the fsm found a designator it doesn’t understand, onlyYMWD
are valid in general and onlyHMS
afterT
,P12A
would be invalid, whilePT5D
, in place ofP5D
would be tooDuplicateDesignator
: the fsm already set that designator, ISO8601 doesn’t allow for duplicate duration unit designators, likeP2D10D
, which should beP12D
MissingNumber
: the fsm encountered a designator before its number, for instance:PD
instead ofP5D
TooManyNumbersForDesignator
: ISO8601 defines the amount of digit characters constructing the number before a designator to be decided by the producing and consuming party. Currently I used the examplary value of 2, which the spec uses and I found hafas to be using. Setting a unit number with more than two digit characters produces this error, for instance inPT120S
.MissingPDesignatorAtStart
: ISO8601 requires all duration strings to start withP
(short forPeriod
), thus making all strings not conforming to this invalid inputs, this error reflects that.12D
andT5H
are invalid.NoDesignatorsAfterWeeksAllowed
: A duration including weeks (W
) is exclusive to all other designators and designator values, makingP2W5D
andP5D1W
invalid inputs.
For instance, in the part of the state machine that handles getting a new character for advancing the state has several possible causes for failure, as lined out before:
1func From(s string) (ISO8601Duration, error) {
2 var duration ISO8601Duration
3
4 if len(s) == 0 {
5 return duration, wrapErr(UnexpectedEof, 0)
6 }
7
8 curState := stateStart
9 var col uint8
10
11 r := strings.NewReader(s)
12
13 for {
14 b, size, err := r.ReadRune()
15 if err != nil {
16 if err != io.EOF {
17 return duration, wrapErr(UnexpectedReaderError, col)
18 } else if curState == stateP {
19 // being in stateP at the end (io.EOF) means we havent
20 // encountered anything after the P, so there were no numbers
21 // or states
22 return duration, wrapErr(UnexpectedEof, col)
23 } else if curState == stateNumber || curState == stateTNumber {
24 // if we are in the state of Number or TNumber we had a number
25 // but no designator at the end
26 return duration, wrapErr(MissingDesignator, col)
27 } else {
28 curState = stateFin
29 }
30 }
31 if size > 1 {
32 return duration, wrapErr(UnexpectedNonAsciiRune, col)
33 }
34 col++
35
36 // ...
37 }
38}
Another case for failure is of course the start->P
transition, which is
mandatory for any valid input:
1func From(s string) (ISO8601Duration, error) {
2 // ...
3 for {
4 // ...
5 switch curState {
6 case stateStart:
7 if b != 'P' {
8 return duration, wrapErr(MissingPDesignatorAtStart, col)
9 }
10 curState = stateP
11 // ...
12 }
13}
Parsing numbers
As I explained above, I went for a maximum of two digit characters to make up the designator value:
1// This parser uses the examplary notion of allowing two numbers before any
2// designator, see: ISO8601 4.4.3.2 Format with designators
3const maxNumCount = 2
This is reflected in the temporary buffer the fsm uses to store the digit characters as it walks the input:
1var curNumCount uint8
2var numBuf [maxNumCount]rune
When encountering an integer it puts this integer into the buffer:
1case stateTNumber:
2 if unicode.IsDigit(b) {
3 if curNumCount+1 > maxNumCount {
4 return duration, wrapErr(TooManyNumbersForDesignator, col)
5 }
6 numBuf[curNumCount] = b
7 curNumCount++
8 curState = stateTNumber
9 }
10 // ...
If it hits a designator, either in the context of time (T
) or not, it uses
this rune buffer to produce an integer to assign to the corresponding field of
the ISO8601Duration
struct:
1const defaultDesignators = "YMWD"
2const timeDesignators = "MHS"
3
4case stateTNumber:
5 // ...
6 if strings.ContainsRune(timeDesignators, b) {
7 if curNumCount == 0 {
8 return duration, wrapErr(MissingNumber, col)
9 }
10 num := numBufferToNumber(numBuf)
11 switch b {
12 case 'H':
13 if duration.hour != 0 {
14 return duration, wrapErr(DuplicateDesignator, col)
15 }
16 duration.hour = float64(num)
17 case 'M':
18 if duration.minute != 0 {
19 return duration, wrapErr(DuplicateDesignator, col)
20 }
21 duration.minute = float64(num)
22 case 'S':
23 if duration.second != 0 {
24 return duration, wrapErr(DuplicateDesignator, col)
25 }
26 duration.second = float64(num)
27 }
28 curNumCount = 0
29 numBuf = [maxNumCount]rune{}
30 curState = stateTDesignator
31 } else {
32 return duration, wrapErr(UnknownDesignator, col)
33 }
numBufferToNumber
is as simple as number conversion gets:
1func numBufferToNumber(buf [maxNumCount]rune) int64 {
2 var i int
3 for _, n := range buf {
4 if n == 0 { // empty number (zero byte) in buffer, stop
5 break
6 }
7 i = (i * 10) + int(n-'0')
8 }
9
10 return int64(i)
11}
I chose this way to omit the allocation of a string and the call to
strconv.ParseInt
by doing the work myself.
The whole machine
1func From(s string) (ISO8601Duration, error) {
2 var duration ISO8601Duration
3
4 if len(s) == 0 {
5 return duration, wrapErr(UnexpectedEof, 0)
6 }
7
8 curState := stateStart
9 var col uint8
10 var curNumCount uint8
11 var numBuf [maxNumCount]rune
12
13 r := strings.NewReader(s)
14
15 for {
16 b, size, err := r.ReadRune()
17 if err != nil {
18 if err != io.EOF {
19 return duration, wrapErr(UnexpectedReaderError, col)
20 } else if curState == stateP {
21 // being in stateP at the end (io.EOF) means we havent
22 // encountered anything after the P, so there were no numbers
23 // or states
24 return duration, wrapErr(UnexpectedEof, col)
25 } else if curState == stateNumber || curState == stateTNumber {
26 // if we are in the state of Number or TNumber we had a number
27 // but no designator at the end
28 return duration, wrapErr(MissingDesignator, col)
29 } else {
30 curState = stateFin
31 }
32 }
33 if size > 1 {
34 return duration, wrapErr(UnexpectedNonAsciiRune, col)
35 }
36 col++
37
38 switch curState {
39 case stateStart:
40 if b != 'P' {
41 return duration, wrapErr(MissingPDesignatorAtStart, col)
42 }
43 curState = stateP
44 case stateP, stateDesignator:
45 if b == 'T' {
46 curState = stateT
47 } else if unicode.IsDigit(b) {
48 if curNumCount > maxNumCount {
49 return duration, wrapErr(TooManyNumbersForDesignator, col)
50 }
51 numBuf[curNumCount] = b
52 curNumCount++
53 curState = stateNumber
54 } else {
55 return duration, wrapErr(MissingNumber, col)
56 }
57 case stateNumber:
58 if unicode.IsDigit(b) {
59 if curNumCount+1 > maxNumCount {
60 return duration, wrapErr(TooManyNumbersForDesignator, col)
61 }
62 numBuf[curNumCount] = b
63 curNumCount++
64 curState = stateNumber
65 } else if strings.ContainsRune(defaultDesignators, b) {
66 if curNumCount == 0 {
67 return duration, wrapErr(MissingNumber, col)
68 }
69 num := numBufferToNumber(numBuf)
70 switch b {
71 case 'Y':
72 if duration.year != 0 {
73 return duration, wrapErr(DuplicateDesignator, col)
74 }
75 duration.year = float64(num)
76 case 'M':
77 if duration.month != 0 {
78 return duration, wrapErr(DuplicateDesignator, col)
79 }
80 duration.month = float64(num)
81 case 'W':
82 if r.Len() != 0 {
83 return duration, wrapErr(NoDesignatorsAfterWeeksAllowed, col)
84 }
85 duration.week = float64(num)
86 case 'D':
87 if duration.day != 0 {
88 return duration, wrapErr(DuplicateDesignator, col)
89 }
90 duration.day = float64(num)
91 }
92 curNumCount = 0
93 numBuf = [maxNumCount]rune{}
94 curState = stateDesignator
95 } else {
96 return duration, wrapErr(UnknownDesignator, col)
97 }
98 case stateT, stateTDesignator:
99 if unicode.IsDigit(b) {
100 if curNumCount > maxNumCount {
101 return duration, wrapErr(TooManyNumbersForDesignator, col)
102 }
103 numBuf[curNumCount] = b
104 curNumCount++
105 curState = stateTNumber
106 } else {
107 return duration, wrapErr(MissingNumber, col)
108 }
109 case stateTNumber:
110 if unicode.IsDigit(b) {
111 if curNumCount+1 > maxNumCount {
112 return duration, wrapErr(TooManyNumbersForDesignator, col)
113 }
114 numBuf[curNumCount] = b
115 curNumCount++
116 curState = stateTNumber
117 } else if strings.ContainsRune(timeDesignators, b) {
118 if curNumCount == 0 {
119 return duration, wrapErr(MissingNumber, col)
120 }
121 num := numBufferToNumber(numBuf)
122 switch b {
123 case 'H':
124 if duration.hour != 0 {
125 return duration, wrapErr(DuplicateDesignator, col)
126 }
127 duration.hour = float64(num)
128 case 'M':
129 if duration.minute != 0 {
130 return duration, wrapErr(DuplicateDesignator, col)
131 }
132 duration.minute = float64(num)
133 case 'S':
134 if duration.second != 0 {
135 return duration, wrapErr(DuplicateDesignator, col)
136 }
137 duration.second = float64(num)
138 }
139 curNumCount = 0
140 numBuf = [maxNumCount]rune{}
141 curState = stateTDesignator
142 } else {
143 return duration, wrapErr(UnknownDesignator, col)
144 }
145 case stateFin:
146 return duration, nil
147 }
148 }
149}
Go time Compatibility
Since the whole usecase for this library is to interact with the go time
package, it needs conversion to do exactly that.
Apply
As the name suggests this function takes a time.Time
, applies the value
encoded in ISO8601Duration
to it and returns a resulting time.Time
instance.
1func (i ISO8601Duration) Apply(t time.Time) time.Time {
2 newT := t.AddDate(int(i.year), int(i.month), int(i.day+i.week*7))
3 d := time.Duration(
4 (i.hour * float64(time.Hour)) +
5 (i.minute * float64(time.Minute)) +
6 (i.second * float64(time.Second)),
7 )
8 return newT.Add(d)
9}
Duration
Duration is a bit more complicated. I had to approximate some values (like
daysPerYear
and daysPerMonth
) which of course doesn’t account for the
whole lap year/lap day fuckery.
1func (i ISO8601Duration) Duration() time.Duration {
2 const (
3 nsPerSecond = int64(time.Second)
4 nsPerMinute = int64(time.Minute)
5 nsPerHour = int64(time.Hour)
6 nsPerDay = int64(24 * time.Hour)
7 nsPerWeek = int64(7 * 24 * time.Hour)
8 daysPerYear = 365.2425
9 daysPerMonth = 30.436875
10 )
11
12 var ns int64
13
14 ns += int64(i.year * daysPerYear * float64(nsPerDay))
15 ns += int64(i.month * daysPerMonth * float64(nsPerDay))
16 ns += int64(i.week * float64(nsPerWeek))
17 ns += int64(i.day * float64(nsPerDay))
18 ns += int64(i.hour * float64(nsPerHour))
19 ns += int64(i.minute * float64(nsPerMinute))
20 ns += int64(i.second * float64(nsPerSecond))
21
22 return time.Duration(ns)
23}
Serializing via the Stringer interface
1// Stringer is implemented by any value that has a String method,
2// which defines the “native” format for that value.
3// The String method is used to print values passed as an operand
4// to any format that accepts a string or to an unformatted printer
5// such as [Print].
6type Stringer interface {
7 String() string
8}
Implementing this is fairly easy: at a high level: It consists of checking for
each field whether its 0, if not write the value (nn
) and its designator
(YMDHMS
). Edgecases are:
- all fields are zero -> write
P0D
- time fields are set, prefix with
T
- field
week
is non zero -> write only the weekPnnW
, nothing else
1func (i ISO8601Duration) String() string {
2 b := strings.Builder{}
3 b.WriteRune('P')
4
5 // If the number of years, months, days, hours, minutes or seconds in any of these expressions equals
6 // zero, the number and the corresponding designator may be absent; however, at least one number
7 // and its designator shall be present
8 if i.year == 0 && i.month == 0 && i.week == 0 && i.day == 0 && i.hour == 0 && i.minute == 0 && i.second == 0 {
9 b.WriteString("0D")
10 return b.String()
11 }
12
13 if i.week > 0 {
14 b.WriteString(strconv.FormatFloat(i.week, 'g', -1, 64))
15 b.WriteRune('W')
16 return b.String()
17 }
18
19 if i.year > 0 {
20 b.WriteString(strconv.FormatFloat(i.year, 'g', -1, 64))
21 b.WriteRune('Y')
22 }
23 if i.month > 0 {
24 b.WriteString(strconv.FormatFloat(i.month, 'g', -1, 64))
25 b.WriteRune('M')
26 }
27 if i.day > 0 {
28 b.WriteString(strconv.FormatFloat(i.day, 'g', -1, 64))
29 b.WriteRune('D')
30 }
31
32 // The designator [T] shall be absent if all of the time components are absent.
33 if i.hour > 0 || i.minute > 0 || i.second > 0 {
34 b.WriteRune('T')
35
36 if i.hour > 0 {
37 b.WriteString(strconv.FormatFloat(i.hour, 'g', -1, 64))
38 b.WriteRune('H')
39 }
40
41 if i.minute > 0 {
42 b.WriteString(strconv.FormatFloat(i.minute, 'g', -1, 64))
43 b.WriteRune('M')
44 }
45
46 if i.second > 0 {
47 b.WriteString(strconv.FormatFloat(i.second, 'g', -1, 64))
48 b.WriteRune('S')
49 }
50 }
51
52 return b.String()
53}
Extensive testing
Since most of my critisism of the existing libraries were either no spec compliance and or accepting weird inputs I made sure to do extensive testing before publishing the library.
Edgecases
Some of these are inputs I tried throwing at other libraries to test their compliance and how in depth their error messages are.
1// TestDurationErr makes sure all expected edgecases are implemented correctly
2func TestDurationErr(t *testing.T) {
3 cases := []string{
4 "", // UnexpectedEof
5 "P", // UnexpectedEof
6 "è", // UnexpectedNonAsciiRune
7 "P1", // MissingDesignator
8 "P1A", // UnknownDesignator
9 "P12D12D", // DuplicateDesignator
10 "P1YD", // MissingNumber
11 "P111Y", // TooManyNumbersForDesignator
12 "Z", // MissingPDesignatorAtStart
13 "P15W2D", // NoDesignatorsAfterWeeksAllowed
14 }
15
16 for _, i := range cases {
17 t.Run(i, func(t *testing.T) {
18 _, err := From(i)
19 if assert.Error(t, err) {
20 fmt.Println(err)
21 }
22 })
23 }
24}
$ go test ./... -v
=== RUN TestDurationErr
=== RUN TestDurationErr/#00
ISO8601DurationError: Unexpected EOF in duration format string, at col: 0
=== RUN TestDurationErr/P
ISO8601DurationError: Unexpected EOF in duration format string, at col: 1
=== RUN TestDurationErr/è
ISO8601DurationError: Unexpected non ascii component in duration format string, at col: 0
=== RUN TestDurationErr/P1
ISO8601DurationError: Missing unit designator, at col: 2
=== RUN TestDurationErr/P1A
ISO8601DurationError: Unknown designator, expected YMWD or after a T, HMS, at col: 3
=== RUN TestDurationErr/P12D12D
ISO8601DurationError: Duplicate designator, at col: 7
=== RUN TestDurationErr/P1YD
ISO8601DurationError: Missing number specifier before unit designator, at col: 4
=== RUN TestDurationErr/P111Y
ISO8601DurationError: Only 2 numbers before any designator allowed, at col: 4
=== RUN TestDurationErr/Z
ISO8601DurationError: Missing [P] designator at the start of the duration format string, at col: 1
=== RUN TestDurationErr/P15W2D
ISO8601DurationError: After [W] designator, no other numbers and designators are allowed, at col: 4
Happy paths
Each testcase crafted so I hit all branches and possibilites the spec defines.
1var testcases = []struct {
2 str string
3 dur ISO8601Duration
4}{
5 {"P0D", ISO8601Duration{}},
6 {"PT15H", ISO8601Duration{hour: 15}},
7 {"P1W", ISO8601Duration{week: 1}},
8 {"P15W", ISO8601Duration{week: 15}},
9 {"P15Y", ISO8601Duration{year: 15}},
10 {"P15Y3M", ISO8601Duration{year: 15, month: 3}},
11 {"P15Y3M41D", ISO8601Duration{year: 15, month: 3, day: 41}},
12 {"PT15M", ISO8601Duration{minute: 15}},
13 {"PT15M10S", ISO8601Duration{minute: 15, second: 10}},
14 {
15 "P3Y6M4DT12H30M5S",
16 ISO8601Duration{
17 year: 3,
18 month: 6,
19 day: 4,
20 hour: 12,
21 minute: 30,
22 second: 5,
23 },
24 },
25}
26
27func TestDurationStringer(t *testing.T) {
28 for _, i := range testcases {
29 t.Run(i.str, func(t *testing.T) {
30 stringified := i.dur.String()
31 assert.Equal(t, i.str, stringified)
32 })
33 }
34}
35
36func TestDuration(t *testing.T) {
37 for _, i := range testcases {
38 t.Run(i.str, func(t *testing.T) {
39 parsed, err := From(i.str)
40 assert.NoError(t, err)
41 assert.Equal(t, i.dur, parsed)
42 })
43 }
44}
45
46func TestBiliteral(t *testing.T) {
47 for _, i := range testcases {
48 t.Run(i.str, func(t *testing.T) {
49 parsed, err := From(i.str)
50 assert.NoError(t, err)
51 assert.Equal(t, i.dur, parsed)
52 stringified := parsed.String()
53 assert.Equal(t, i.str, stringified)
54 })
55 }
56}