fork of indigo with slightly nicer lexgen
at main 3.7 kB view raw
1package syntax 2 3import ( 4 "errors" 5 "fmt" 6 "regexp" 7 "strings" 8 "time" 9) 10 11const ( 12 // Preferred atproto Datetime string syntax, for use with [time.Format]. 13 // 14 // Note that *parsing* syntax is more flexible. 15 AtprotoDatetimeLayout = "2006-01-02T15:04:05.999Z" 16) 17 18// Represents the a Datetime in string format, as would pass Lexicon syntax validation: the intersection of RFC-3339 and ISO-8601 syntax. 19// 20// Always use [ParseDatetime] instead of wrapping strings directly, especially when working with network input. 21// 22// Syntax is specified at: https://atproto.com/specs/lexicon#datetime 23type Datetime string 24 25var datetimeRegex = regexp.MustCompile(`^[0-9]{4}-[01][0-9]-[0-3][0-9]T[0-2][0-9]:[0-6][0-9]:[0-6][0-9](.[0-9]{1,20})?(Z|([+-][0-2][0-9]:[0-5][0-9]))$`) 26 27func ParseDatetime(raw string) (Datetime, error) { 28 if raw == "" { 29 return "", errors.New("expected datetime, got empty string") 30 } 31 if len(raw) > 64 { 32 return "", errors.New("Datetime too long (max 64 chars)") 33 } 34 35 if !datetimeRegex.MatchString(raw) { 36 return "", errors.New("Datetime syntax didn't validate via regex") 37 } 38 if strings.HasSuffix(raw, "-00:00") { 39 return "", errors.New("Datetime can't use '-00:00' for UTC timezone, must use '+00:00', per ISO-8601") 40 } 41 // ensure that the datetime actually parses using golang time lib 42 _, err := time.Parse(time.RFC3339Nano, raw) 43 if err != nil { 44 return "", err 45 } 46 return Datetime(raw), nil 47} 48 49// Validates and converts a string to a golang [time.Time] in a single step. 50func ParseDatetimeTime(raw string) (time.Time, error) { 51 d, err := ParseDatetime(raw) 52 if err != nil { 53 var zero time.Time 54 return zero, err 55 } 56 return d.Time(), nil 57} 58 59// Similar to ParseDatetime, but more flexible about some parsing. 60// 61// Note that this may mutate the internal string, so a round-trip will fail. This is intended for working with legacy/broken records, not to be used in an ongoing way. 62var hasTimezoneRegex = regexp.MustCompile(`^.*(([+-]\d\d:?\d\d)|[a-zA-Z])$`) 63 64func ParseDatetimeLenient(raw string) (Datetime, error) { 65 // fast path: it is a valid overall datetime 66 valid, err := ParseDatetime(raw) 67 if nil == err { 68 return valid, nil 69 } 70 71 if strings.HasSuffix(raw, "-00:00") { 72 return ParseDatetime(strings.Replace(raw, "-00:00", "+00:00", 1)) 73 } 74 if strings.HasSuffix(raw, "-0000") { 75 return ParseDatetime(strings.Replace(raw, "-0000", "+00:00", 1)) 76 } 77 if strings.HasSuffix(raw, "+0000") { 78 return ParseDatetime(strings.Replace(raw, "+0000", "+00:00", 1)) 79 } 80 81 // try adding timezone if it is missing 82 if !hasTimezoneRegex.MatchString(raw) { 83 withTZ, err := ParseDatetime(raw + "Z") 84 if nil == err { 85 return withTZ, nil 86 } 87 } 88 89 return "", fmt.Errorf("Datetime could not be parsed, even leniently: %v", err) 90} 91 92// Parses the Datetime string in to a golang [time.Time]. 93// 94// This method assumes that [ParseDatetime] was used to create the Datetime, which already verified parsing, and thus that [time.Parse] will always succeed. In the event of an error, zero/nil will be returned. 95func (d Datetime) Time() time.Time { 96 var zero time.Time 97 ret, err := time.Parse(time.RFC3339Nano, d.String()) 98 if err != nil { 99 return zero 100 } 101 return ret 102} 103 104// Creates a new valid Datetime string matching the current time, in preferred syntax. 105func DatetimeNow() Datetime { 106 t := time.Now().UTC() 107 return Datetime(t.Format(AtprotoDatetimeLayout)) 108} 109 110func (d Datetime) String() string { 111 return string(d) 112} 113 114func (d Datetime) MarshalText() ([]byte, error) { 115 return []byte(d.String()), nil 116} 117 118func (d *Datetime) UnmarshalText(text []byte) error { 119 datetime, err := ParseDatetime(string(text)) 120 if err != nil { 121 return err 122 } 123 *d = datetime 124 return nil 125}