new /x/jsonx and /x/mathx util sub-packages

This commit is contained in:
Gerasimos (Makis) Maropoulos 2021-11-06 20:25:25 +02:00
parent 485395190b
commit 51fc2f35ca
No known key found for this signature in database
GPG Key ID: ACAB76DFB0DD3F3B
9 changed files with 567 additions and 0 deletions

View File

@ -28,6 +28,10 @@ The codebase for Dependency Injection, Internationalization and localization and
## Fixes and Improvements ## Fixes and Improvements
- New [x/jsonx](x/jsonx) sub-package for JSON type helpers.
- New [x/mathx](x/mathx) sub-package for math related functions.
- New [/x/client](x/client) HTTP Client sub-package. - New [/x/client](x/client) HTTP Client sub-package.
- New `email` builtin path parameter type. Example: - New `email` builtin path parameter type. Example:

54
x/jsonx/duration.go Normal file
View File

@ -0,0 +1,54 @@
package jsonx
import (
"database/sql/driver"
"encoding/json"
"errors"
"math"
"time"
)
// Duration is a JSON representation of the standard Duration type, until Go version 2 supports it under the hoods.
type Duration time.Duration
func (d Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(time.Duration(d).String())
}
func (d *Duration) UnmarshalJSON(b []byte) error {
var v interface{}
if err := json.Unmarshal(b, &v); err != nil {
return err
}
switch value := v.(type) {
case float64:
*d = Duration(value)
return nil
case string:
v, err := time.ParseDuration(value)
if err != nil {
return err
}
*d = Duration(v)
return nil
default:
return errors.New("invalid duration")
}
}
func (d Duration) ToDuration() time.Duration {
return time.Duration(d)
}
func (d Duration) Value() (driver.Value, error) {
return int64(d), nil
}
// Set sets the value of duration in nanoseconds.
func (d *Duration) Set(v float64) {
if math.IsNaN(v) {
return
}
*d = Duration(v)
}

112
x/jsonx/iso8601.go Normal file
View File

@ -0,0 +1,112 @@
package jsonx
import (
"fmt"
"strconv"
"strings"
"time"
)
const (
// ISO8601Layout holds the time layout for the the javascript iso time.
// Read more at: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString.
ISO8601Layout = "2006-01-02T15:04:05"
// ISO8601ZLayout same as ISO8601Layout but with the timezone suffix.
ISO8601ZLayout = "2006-01-02T15:04:05Z"
)
// ISO8601 describes a time compatible with javascript time format.
type ISO8601 time.Time
// ParseISO8601 reads from "s" and returns the ISO8601 time.
func ParseISO8601(s string) (ISO8601, error) {
if s == "" || s == "null" {
return ISO8601{}, nil
}
var (
tt time.Time
err error
)
if s[len(s)-1] == 'Z' {
tt, err = time.Parse(ISO8601ZLayout, s)
} else {
tt, err = time.Parse(ISO8601Layout, s)
}
if err != nil {
return ISO8601{}, err
}
return ISO8601(tt.UTC()), nil
}
// UnmarshalJSON parses the "b" into ISO8601 time.
func (t *ISO8601) UnmarshalJSON(b []byte) error {
if len(b) == 0 {
return nil
}
s := strings.Trim(string(b), `"`)
tt, err := ParseISO8601(s)
if err != nil {
return err
}
*t = tt
return nil
}
// MarshalJSON writes a quoted string in the ISO8601 time format.
func (t ISO8601) MarshalJSON() ([]byte, error) {
if s := t.String(); s != "" {
s = strconv.Quote(s)
return []byte(s), nil
}
return nullLiteral, nil // Note: if the front-end wants an empty string instead I must change that.
}
// ToTime returns the unwrapped *t to time.Time.
func (t *ISO8601) ToTime() time.Time {
tt := time.Time(*t)
return tt
}
// IsZero reports whether "t" is zero time.
// It completes the pg.Zeroer interface.
func (t ISO8601) IsZero() bool {
return time.Time(t).IsZero()
}
// String returns the text representation of the "t" using the ISO8601 time layout.
func (t ISO8601) String() string {
tt := t.ToTime()
if tt.IsZero() {
return ""
}
return tt.Format(ISO8601Layout)
}
// Scan completes the sql driver.Scanner interface.
func (t *ISO8601) Scan(src interface{}) error {
switch v := src.(type) {
case time.Time: // type was set to timestamp
if v.IsZero() {
return nil // don't set zero, ignore it.
}
*t = ISO8601(v)
case string:
tt, err := ParseISO8601(v)
if err != nil {
return err
}
*t = tt
default:
return fmt.Errorf("ISO8601: unknown type of: %T", v)
}
return nil
}

21
x/jsonx/jsonx.go Normal file
View File

@ -0,0 +1,21 @@
package jsonx
import "bytes"
var (
quoteLiteral = '"'
emptyQuoteBytes = []byte(`""`)
nullLiteral = []byte("null")
)
func isNull(b []byte) bool {
return len(b) == 0 || bytes.Equal(b, nullLiteral)
}
func trimQuotesFunc(r rune) bool {
return r == quoteLiteral
}
func trimQuotes(b []byte) []byte {
return bytes.TrimFunc(b, trimQuotesFunc)
}

108
x/jsonx/kitchen_time.go Normal file
View File

@ -0,0 +1,108 @@
package jsonx
import (
"fmt"
"strconv"
"time"
)
// KitckenTimeLayout represents the "3:04 PM" Go time format, similar to time.Kitcken.
const KitckenTimeLayout = "3:04 PM"
// KitckenTime holds a json "3:04 PM" time.
type KitckenTime time.Time
// ParseKitchenTime reads from "s" and returns the KitckenTime time.
func ParseKitchenTime(s string) (KitckenTime, error) {
if s == "" || s == "null" {
return KitckenTime{}, nil
}
var (
tt time.Time
err error
)
tt, err = time.Parse(KitckenTimeLayout, s)
if err != nil {
return KitckenTime{}, err
}
return KitckenTime(tt.UTC()), nil
}
// UnmarshalJSON binds the json "data" to "t" with the `KitckenTimeLayout`.
func (t *KitckenTime) UnmarshalJSON(data []byte) error {
if isNull(data) {
return nil
}
data = trimQuotes(data)
if len(data) == 0 {
return nil
}
tt, err := time.Parse(KitckenTimeLayout, string(data))
if err != nil {
return err
}
*t = KitckenTime(tt)
return nil
}
// MarshalJSON returns the json representation of the "t".
func (t KitckenTime) MarshalJSON() ([]byte, error) {
if s := t.String(); s != "" {
s = strconv.Quote(s)
return []byte(s), nil
}
return emptyQuoteBytes, nil
}
// IsZero reports whether "t" is zero time.
// It completes the pg.Zeroer interface.
func (t KitckenTime) IsZero() bool {
return t.Value().IsZero()
}
// Value returns the standard time type.
func (t KitckenTime) Value() time.Time {
return time.Time(t)
}
// String returns the text representation of the date
// formatted based on the `KitckenTimeLayout`.
// If date is zero it returns an empty string.
func (t KitckenTime) String() string {
tt := t.Value()
if tt.IsZero() {
return ""
}
return tt.Format(KitckenTimeLayout)
}
// Scan completes the pg and native sql driver.Scanner interface
// reading functionality of a custom type.
func (t *KitckenTime) Scan(src interface{}) error {
switch v := src.(type) {
case time.Time: // type was set to timestamp
if v.IsZero() {
return nil // don't set zero, ignore it.
}
*t = KitckenTime(v)
case string:
tt, err := ParseKitchenTime(v)
if err != nil {
return err
}
*t = tt
default:
return fmt.Errorf("KitckenTime: unknown type of: %T", v)
}
return nil
}

View File

@ -0,0 +1,39 @@
package jsonx
import (
"encoding/json"
"testing"
"time"
)
func TestJSONKitckenTime(t *testing.T) {
data := `{"start": "8:33 AM", "end": "3:04 PM", "nothing": null, "empty": ""}`
v := struct {
Start KitckenTime `json:"start"`
End KitckenTime `json:"end"`
Nothing KitckenTime `json:"nothing"`
Empty KitckenTime `json:"empty"`
}{}
err := json.Unmarshal([]byte(data), &v)
if err != nil {
t.Fatal(err)
}
if !v.Nothing.IsZero() {
t.Fatalf("expected 'nothing' to be zero but got: %v", v.Nothing)
}
if !v.Empty.IsZero() {
t.Fatalf("expected 'empty' to be zero but got: %v", v.Empty)
}
loc := time.UTC
if expected, got := time.Date(0, time.January, 1, 8, 33, 0, 0, loc), v.Start.Value(); expected != got {
t.Fatalf("expected 'start' to be: %v but got: %v", expected, got)
}
if expected, got := time.Date(0, time.January, 1, 15, 4, 0, 0, loc), v.End.Value(); expected != got {
t.Fatalf("expected 'start' to be: %v but got: %v", expected, got)
}
}

113
x/jsonx/simple_date.go Normal file
View File

@ -0,0 +1,113 @@
package jsonx
import (
"database/sql/driver"
"fmt"
"strconv"
"time"
)
// SimpleDateLayout represents the "year-month-day" Go time format.
const SimpleDateLayout = "2006-01-02"
// SimpleDate holds a json "year-month-day" time.
type SimpleDate time.Time
// ParseSimpleDate reads from "s" and returns the SimpleDate time.
func ParseSimpleDate(s string) (SimpleDate, error) {
if s == "" || s == "null" {
return SimpleDate{}, nil
}
var (
tt time.Time
err error
)
tt, err = time.Parse(SimpleDateLayout, s)
if err != nil {
return SimpleDate{}, err
}
return SimpleDate(tt.UTC()), nil
}
// UnmarshalJSON binds the json "data" to "t" with the `SimpleDateLayout`.
func (t *SimpleDate) UnmarshalJSON(data []byte) error {
if isNull(data) {
return nil
}
data = trimQuotes(data)
dataStr := string(data)
if len(dataStr) == 0 {
return nil // as an excepption here, allow empty "" on simple dates, as the server would render it on a response: https://endomedical.slack.com/archives/D02BF660JA1/p1630486704048100.
}
tt, err := time.Parse(SimpleDateLayout, dataStr)
if err != nil {
return err
}
*t = SimpleDate(tt)
return nil
}
// MarshalJSON returns the json representation of the "t".
func (t SimpleDate) MarshalJSON() ([]byte, error) {
if s := t.String(); s != "" {
s = strconv.Quote(s)
return []byte(s), nil
}
return emptyQuoteBytes, nil
}
// IsZero reports whether "t" is zero time.
// It completes the pg.Zeroer interface.
func (t SimpleDate) IsZero() bool {
return t.ToTime().IsZero()
}
// ToTime returns the standard time type.
func (t SimpleDate) ToTime() time.Time {
return time.Time(t)
}
func (t SimpleDate) Value() (driver.Value, error) {
return t.String(), nil
}
// String returns the text representation of the date
// formatted based on the `SimpleDateLayout`.
// If date is zero it returns an empty string.
func (t SimpleDate) String() string {
tt := t.ToTime()
if tt.IsZero() {
return ""
}
return tt.Format(SimpleDateLayout)
}
// Scan completes the pg and native sql driver.Scanner interface
// reading functionality of a custom type.
func (t *SimpleDate) Scan(src interface{}) error {
switch v := src.(type) {
case time.Time: // type was set to timestamp
if v.IsZero() {
return nil // don't set zero, ignore it.
}
*t = SimpleDate(v)
case string:
tt, err := ParseSimpleDate(v)
if err != nil {
return err
}
*t = tt
default:
return fmt.Errorf("SimpleDate: unknown type of: %T", v)
}
return nil
}

88
x/jsonx/time_notation.go Normal file
View File

@ -0,0 +1,88 @@
package jsonx
import (
"bytes"
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
)
// TimeNotationDuration is a JSON representation of the standard Duration type in 00:00:00 (hour, minute seconds).
type TimeNotationDuration time.Duration
func fmtDuration(d time.Duration) string {
d = d.Round(time.Minute)
h := d / time.Hour
d -= h * time.Hour
m := d / time.Minute
return fmt.Sprintf("%02d:%02d", h, m)
}
func (d TimeNotationDuration) MarshalJSON() ([]byte, error) {
v := d.ToDuration()
format := fmtDuration(v)
return []byte(strconv.Quote(format)), nil
}
func (d *TimeNotationDuration) UnmarshalJSON(b []byte) error {
if len(b) == 0 || bytes.Equal(b, nullLiteral) || bytes.Equal(b, emptyQuoteBytes) { // if null or empty don't throw an error.
return nil
}
var v interface{}
if err := json.Unmarshal(b, &v); err != nil {
return err
}
switch value := v.(type) {
case float64:
*d = TimeNotationDuration(value)
return nil
case string:
entries := strings.SplitN(value, ":", 2)
if len(entries) < 2 {
return fmt.Errorf("invalid duration format: expected hours:minutes:seconds (e.g. 01:05) but got: %s", value)
}
hours, err := strconv.Atoi(entries[0])
if err != nil {
return err
}
minutes, err := strconv.Atoi(entries[1])
if err != nil {
return err
}
format := fmt.Sprintf("%02dh%02dm", hours, minutes)
v, err := time.ParseDuration(format)
if err != nil {
return err
}
*d = TimeNotationDuration(v)
return nil
default:
return errors.New("invalid duration")
}
}
func (d TimeNotationDuration) ToDuration() time.Duration {
return time.Duration(d)
}
func (d TimeNotationDuration) Value() (driver.Value, error) {
return int64(d), nil
}
// Set sets the value of duration in nanoseconds.
func (d *TimeNotationDuration) Set(v float64) {
if math.IsNaN(v) {
return
}
*d = TimeNotationDuration(v)
}

28
x/mathx/round.go Normal file
View File

@ -0,0 +1,28 @@
package mathx
import "math"
// Round rounds the "input" on "roundOn" (e.g. 0.5) on "places" digits.
func Round(input float64, roundOn float64, places float64) float64 {
pow := math.Pow(10, places)
digit := pow * input
_, div := math.Modf(digit)
if div >= roundOn {
return math.Ceil(digit) / pow
}
return math.Floor(digit) / pow
}
// RoundUp rounds up the "input" up to "places" digits.
func RoundUp(input float64, places float64) float64 {
pow := math.Pow(10, places)
return math.Ceil(pow*input) / pow
}
// RoundDown rounds down the "input" up to "places" digits.
func RoundDown(input float64, places float64) float64 {
pow := math.Pow(10, places)
return math.Floor(pow*input) / pow
}